generated from ttipo/project-base
feat(backend): 일정 함께 관리 API — 초대 코드로 달력 연동
가족·친구가 한 달력을 함께 쓸 수 있게 한다. - calendar_groups · calendar_group_members (한 사람 한 그룹, 최대 4명), 회원마다 개인 초대 코드(RUP-6자) - 연동 상태·초대 미리보기·코드로 연동·연동 해제 — 해제하면 내 일정만 남고, 1명 남은 그룹은 지운다 - 일정·반복·완료는 그룹 전체가 보고 고칠 수 있다. 응답에 만든 사람·대상 요약을 담고, 남의 일정을 고칠 때 대상은 그 사람의 아이로 확인한다 - 연동·해제는 advisory lock 으로 줄 세워 인원 초과·중복 그룹을 막는다 - 파일 열람 정책 — 연동된 사람의 프로필·아이 사진 - 초대 코드 생성기·고유 충돌 재시도·대상 요약을 공통으로 옮겨 다이어리와 함께 쓴다 - 에러 코드 SCHED_001~005 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,3 +27,20 @@ export function isUniqueViolation(error: unknown): boolean {
|
||||
export function isForeignKeyViolation(error: unknown): boolean {
|
||||
return pgCodeOf(error) === PG_FOREIGN_KEY_VIOLATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유니크 위반이면 처음부터 다시 한다 — 무작위로 뽑은 코드가 겹친 경우.
|
||||
* [run] 은 매번 새 값을 뽑아야 한다. [attempts] 번 모두 겹치면 마지막 오류를 그대로 던진다.
|
||||
*/
|
||||
export async function retryOnUniqueViolation<T>(
|
||||
run: () => Promise<T>,
|
||||
attempts: number,
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error) || attempt >= attempts) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { INVITE_CODE_ALPHABET, inviteCodeFormat } from './invite-code';
|
||||
|
||||
describe('초대 코드', () => {
|
||||
const six = inviteCodeFormat(6);
|
||||
const four = inviteCodeFormat(4);
|
||||
|
||||
it('RUP- 뒤에 정한 길이만큼 붙인다', () => {
|
||||
expect(six.length).toBe(10);
|
||||
expect(four.length).toBe(8);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
expect(six.random()).toMatch(/^RUP-[A-Z2-9]{6}$/);
|
||||
expect(four.random()).toMatch(/^RUP-[A-Z2-9]{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('헷갈리는 글자(0·O·1·I)는 쓰지 않는다', () => {
|
||||
expect(INVITE_CODE_ALPHABET).not.toMatch(/[0O1I]/);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const body = six.random().slice('RUP-'.length);
|
||||
for (const ch of body) expect(INVITE_CODE_ALPHABET).toContain(ch);
|
||||
}
|
||||
});
|
||||
|
||||
it('입력한 코드는 앞뒤 공백을 지우고 대문자로 맞춘다', () => {
|
||||
expect(six.normalize(' rup-7kq2mx ')).toBe('RUP-7KQ2MX');
|
||||
expect(four.normalize('rup-7kq2')).toBe('RUP-7KQ2');
|
||||
});
|
||||
|
||||
it('형식이 아니면 null — 길이가 다르거나 쓰지 않는 글자가 있으면', () => {
|
||||
expect(six.normalize('RUP-7KQ2M')).toBeNull();
|
||||
expect(six.normalize('RUP-7KQ2MXX')).toBeNull();
|
||||
expect(six.normalize('RUP-0KQ2MX')).toBeNull();
|
||||
expect(six.normalize('ABC-7KQ2MX')).toBeNull();
|
||||
expect(six.normalize('7KQ2MX')).toBeNull();
|
||||
expect(six.normalize('')).toBeNull();
|
||||
// 길이가 다른 종류의 코드는 서로 받지 않는다
|
||||
expect(four.normalize('RUP-7KQ2MX')).toBeNull();
|
||||
expect(six.normalize('RUP-7KQ2')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다).
|
||||
// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다.
|
||||
|
||||
/** 코드 앞머리 — 앱이 보여 주는 형식 */
|
||||
const INVITE_CODE_PREFIX = 'RUP-';
|
||||
|
||||
/** 헷갈리는 글자(0·O·1·I)를 뺀 대문자·숫자 */
|
||||
export const INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
/** 한 종류의 초대 코드 — 뒷부분 길이가 정해진 형식 */
|
||||
export interface InviteCodeFormat {
|
||||
/** 저장 길이 — `RUP-` + 뒷부분 */
|
||||
readonly length: number;
|
||||
/** 새 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
|
||||
random(): string;
|
||||
/** 사람이 입력한 코드 → 저장 형식 (앞뒤 공백 제거·대문자). 형식이 아니면 null */
|
||||
normalize(input: string): string | null;
|
||||
}
|
||||
|
||||
/** 뒷부분이 [bodyLength] 자인 초대 코드 형식 */
|
||||
export function inviteCodeFormat(bodyLength: number): InviteCodeFormat {
|
||||
const pattern = new RegExp(
|
||||
`^${INVITE_CODE_PREFIX}[${INVITE_CODE_ALPHABET}]{${bodyLength}}$`,
|
||||
);
|
||||
return {
|
||||
length: INVITE_CODE_PREFIX.length + bodyLength,
|
||||
random: () => {
|
||||
let body = '';
|
||||
for (let i = 0; i < bodyLength; i += 1) {
|
||||
body += INVITE_CODE_ALPHABET[randomInt(0, INVITE_CODE_ALPHABET.length)];
|
||||
}
|
||||
return `${INVITE_CODE_PREFIX}${body}`;
|
||||
},
|
||||
normalize: (input) => {
|
||||
const code = input.trim().toUpperCase();
|
||||
return pattern.test(code) ? code : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user