diff --git a/firestore.indexes.json b/firestore.indexes.json index 4beb109..b0f1f4b 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -63,6 +63,15 @@ { "fieldPath": "tierPoints", "order": "ASCENDING" }, { "fieldPath": "__name__", "order": "ASCENDING" } ] + }, + { + "collectionGroup": "pointLedger", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "createdAt", "order": "DESCENDING" }, + { "fieldPath": "seq", "order": "DESCENDING" }, + { "fieldPath": "__name__", "order": "DESCENDING" } + ] } ], "fieldOverrides": [] diff --git a/firestore.rules b/firestore.rules index 56c79d9..1d56c27 100644 --- a/firestore.rules +++ b/firestore.rules @@ -4,12 +4,25 @@ service cloud.firestore { match /databases/{database}/documents { match /users/{uid} { allow read: if request.auth != null && request.auth.uid == uid; - allow write: if false; + allow update: if request.auth != null && request.auth.uid == uid && + request.resource.data.diff(resource.data) + .affectedKeys().hasOnly(['fcmToken']); + allow create, delete: if false; match /voteHistory/{date} { allow read: if request.auth != null && request.auth.uid == uid; allow write: if false; } + + match /attendance/{month} { + allow read: if request.auth != null && request.auth.uid == uid; + allow write: if false; + } + + match /pointLedger/{id} { + allow read: if request.auth != null && request.auth.uid == uid; + allow write: if false; + } } match /games/{gameId} { diff --git a/src/handlers/attendanceHandlers.ts b/src/handlers/attendanceHandlers.ts new file mode 100644 index 0000000..da926a9 --- /dev/null +++ b/src/handlers/attendanceHandlers.ts @@ -0,0 +1,24 @@ +import { onRequest } from "firebase-functions/https"; +import { requireAuthToken } from "../middleware/auth"; +import { sendError } from "../middleware/errors"; +import { checkIn, getMonth } from "../services/attendanceService"; + +export const attendance = onRequest(async (req, res) => { + try { + if (req.method === "POST" && req.path === "/check-in") { + const token = await requireAuthToken(req); + const result = await checkIn(token, req.body ?? {}); + res.status(200).json(result); + return; + } + if (req.method === "GET" && req.path === "/month") { + const token = await requireAuthToken(req); + const result = await getMonth(token, req.query.month); + res.status(200).json(result); + return; + } + res.status(404).json({ error: "not found" }); + } catch (err) { + sendError(res, err); + } +}); diff --git a/src/handlers/userHandlers.ts b/src/handlers/userHandlers.ts index 98745ad..4c4b25c 100644 --- a/src/handlers/userHandlers.ts +++ b/src/handlers/userHandlers.ts @@ -7,6 +7,7 @@ import { deleteMe, getMe, updateMe, + updateNotifications, } from "../services/userService"; export const user = onRequest(async (req, res) => { @@ -35,6 +36,12 @@ export const user = onRequest(async (req, res) => { res.status(200).json({ user: { uid: token.uid, ...u } }); return; } + if (req.method === "PATCH" && req.path === "/notifications") { + const token = await requireAuthToken(req); + const notifications = await updateNotifications(token, req.body ?? {}); + res.status(200).json({ notifications }); + return; + } if (req.method === "DELETE" && req.path === "/") { const token = await requireAuthToken(req); await deleteMe(token); diff --git a/src/index.ts b/src/index.ts index a8ba253..dd77530 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ export { prediction } from "./handlers/predictionHandlers"; export { stats } from "./handlers/statsHandlers"; export { admin } from "./handlers/adminHandlers"; export { debug } from "./handlers/debugHandlers"; +export { attendance } from "./handlers/attendanceHandlers"; export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { dailyArchive } from "./scheduled/dailyArchive"; +export { attendanceReminder } from "./scheduled/attendanceReminder"; export { onGameCompleted } from "./triggers/onGameCompleted"; diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 35b3037..0b1cb3f 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -4,7 +4,8 @@ export class HttpError extends Error { constructor( public status: number, message: string, - public code?: string + public code?: string, + public details?: Record ) { super(message); } @@ -12,9 +13,10 @@ export class HttpError extends Error { export function sendError(res: express.Response, err: unknown): void { if (err instanceof HttpError) { - const body = err.code + const body: Record = err.code ? { error: err.code, message: err.message } : { error: err.message }; + if (err.details) Object.assign(body, err.details); res.status(err.status).json(body); return; } diff --git a/src/repositories/attendanceRepository.ts b/src/repositories/attendanceRepository.ts new file mode 100644 index 0000000..129451c --- /dev/null +++ b/src/repositories/attendanceRepository.ts @@ -0,0 +1,30 @@ +import { + type DocumentReference, + type Transaction, +} from "firebase-admin/firestore"; +import { firestore } from "../firebase"; +import type { AttendanceMonthDoc } from "../types/panit"; + +const USERS = "users"; +const ATTENDANCE = "attendance"; + +export function monthDocRef(uid: string, month: string): DocumentReference { + return firestore.collection(USERS).doc(uid).collection(ATTENDANCE).doc(month); +} + +export async function getMonthDoc( + uid: string, + month: string +): Promise { + const snap = await monthDocRef(uid, month).get(); + return snap.exists ? (snap.data() as AttendanceMonthDoc) : null; +} + +export async function getMonthDocTx( + tx: Transaction, + uid: string, + month: string +): Promise { + const snap = await tx.get(monthDocRef(uid, month)); + return snap.exists ? (snap.data() as AttendanceMonthDoc) : null; +} diff --git a/src/repositories/pointLedgerRepository.ts b/src/repositories/pointLedgerRepository.ts new file mode 100644 index 0000000..3e1c3d2 --- /dev/null +++ b/src/repositories/pointLedgerRepository.ts @@ -0,0 +1,78 @@ +import { + FieldValue, + type DocumentReference, + type Transaction, +} from "firebase-admin/firestore"; +import { firestore } from "../firebase"; +import type { PointLedgerEntry, PointLedgerType } from "../types/panit"; + +const USERS = "users"; +const LEDGER = "pointLedger"; + +function ledgerCollection(uid: string) { + return firestore.collection(USERS).doc(uid).collection(LEDGER); +} + +/** + * 가장 최근 ledger row의 `balanceAfter`. 없으면 0. + * + * 정렬은 `createdAt desc, seq desc` — 같은 트랜잭션 내 다중 row가 동일 + * `serverTimestamp()`를 갖는 경우에도 `seq`로 안정 정렬한다. + */ +export async function getLatestBalance(uid: string): Promise { + const snap = await ledgerCollection(uid) + .orderBy("createdAt", "desc") + .orderBy("seq", "desc") + .limit(1) + .get(); + if (snap.empty) return 0; + return (snap.docs[0].data() as PointLedgerEntry).balanceAfter; +} + +/** + * 트랜잭션 컨텍스트의 잔액 조회. `tx.get(query)` 사용. + */ +export async function getLatestBalanceTx( + tx: Transaction, + uid: string +): Promise { + const query = ledgerCollection(uid) + .orderBy("createdAt", "desc") + .orderBy("seq", "desc") + .limit(1); + const snap = await tx.get(query); + if (snap.empty) return 0; + return (snap.docs[0].data() as PointLedgerEntry).balanceAfter; +} + +export interface NewLedgerEntry { + type: PointLedgerType; + amount: number; + balanceAfter: number; + seq: number; + refMonth?: string; + refDay?: number; +} + +/** + * 트랜잭션 안에서 새 ledger row를 생성한다. autoId는 미리 할당하므로 + * 호출 순서와 무관하게 각 row가 고유 doc을 갖는다. + */ +export function createLedgerEntryTx( + tx: Transaction, + uid: string, + entry: NewLedgerEntry +): DocumentReference { + const ref = ledgerCollection(uid).doc(); + const data: Record = { + type: entry.type, + amount: entry.amount, + balanceAfter: entry.balanceAfter, + seq: entry.seq, + createdAt: FieldValue.serverTimestamp(), + }; + if (entry.refMonth !== undefined) data.refMonth = entry.refMonth; + if (entry.refDay !== undefined) data.refDay = entry.refDay; + tx.create(ref, data); + return ref; +} diff --git a/src/scheduled/attendanceReminder.ts b/src/scheduled/attendanceReminder.ts new file mode 100644 index 0000000..77ff690 --- /dev/null +++ b/src/scheduled/attendanceReminder.ts @@ -0,0 +1,141 @@ +import { logger } from "firebase-functions"; +import { onSchedule } from "firebase-functions/scheduler"; +import { FieldValue } from "firebase-admin/firestore"; +import { getMessaging } from "firebase-admin/messaging"; +import { firestore } from "../firebase"; +import { monthOf, todayKst } from "../types/dateString"; +import type { AttendanceMonthDoc, User } from "../types/panit"; + +const PUSH_TYPE = "attendance_reminder"; +const PUSH_ROUTE = "/attendance"; +const MULTICAST_BATCH_SIZE = 500; + +/** + * KST 기준 현재 슬롯("HH:MM"). 분 단위는 0/30으로 floor. + */ +function currentSlot(now: Date): string { + const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const hh = String(kst.getUTCHours()).padStart(2, "0"); + const mm = kst.getUTCMinutes() < 30 ? "00" : "30"; + return `${hh}:${mm}`; +} + +interface ReminderTarget { + uid: string; + fcmToken: string; +} + +/** + * 이번 슬롯에 미출석 푸시를 보낼 대상자를 산출한다. + * + * 조건: `attendanceReminderSlot == slot` AND `notifications.attendance == true` + * AND fcmToken 보유 AND 오늘 attendance 문서에 todayDay 미포함. + * + * 1차 쿼리는 슬롯 매칭만 하고 나머지는 메모리에서 필터한다 (composite index 회피). + */ +async function collectTargets( + slot: string, + todayDay: number, + monthKey: string +): Promise { + const snap = await firestore + .collection("users") + .where("attendanceReminderSlot", "==", slot) + .get(); + + const targets: ReminderTarget[] = []; + await Promise.all( + snap.docs.map(async (doc) => { + const user = doc.data() as Partial; + if (user.notifications?.attendance !== true) return; + if (!user.fcmToken) return; + + const monthSnap = await doc.ref + .collection("attendance") + .doc(monthKey) + .get(); + const days = monthSnap.exists + ? (monthSnap.data() as AttendanceMonthDoc).days + : []; + if (days.includes(todayDay)) return; + + targets.push({ uid: doc.id, fcmToken: user.fcmToken }); + }) + ); + return targets; +} + +/** + * 무효 토큰 응답 코드. 해당 사용자의 fcmToken 필드를 제거한다. + */ +const INVALID_TOKEN_CODES = new Set([ + "messaging/registration-token-not-registered", + "messaging/invalid-registration-token", + "messaging/invalid-argument", +]); + +async function sendBatch(targets: ReminderTarget[]): Promise { + if (targets.length === 0) return; + + const messaging = getMessaging(); + const response = await messaging.sendEachForMulticast({ + tokens: targets.map((t) => t.fcmToken), + data: { type: PUSH_TYPE, route: PUSH_ROUTE }, + }); + + const invalidUids: string[] = []; + response.responses.forEach((res, idx) => { + if (!res.success) { + const code = res.error?.code ?? "unknown"; + logger.warn( + `attendanceReminder: send failed uid=${targets[idx].uid} code=${code}` + ); + if (INVALID_TOKEN_CODES.has(code)) invalidUids.push(targets[idx].uid); + } + }); + + if (invalidUids.length > 0) { + await Promise.all( + invalidUids.map((uid) => + firestore + .collection("users") + .doc(uid) + .update({ fcmToken: FieldValue.delete() }) + .catch((err) => { + logger.error(`failed to clear fcmToken uid=${uid}`, err); + }) + ) + ); + } + + logger.info( + `attendanceReminder: sent=${response.successCount} failed=${response.failureCount} cleared=${invalidUids.length}` + ); +} + +export async function runAttendanceReminder(now: Date): Promise { + const slot = currentSlot(now); + const today = todayKst(); + const todayDay = Number(today.slice(8, 10)); + const monthKey = monthOf(today); + + const targets = await collectTargets(slot, todayDay, monthKey); + logger.info( + `attendanceReminder slot=${slot} today=${today} targets=${targets.length}` + ); + + for (let i = 0; i < targets.length; i += MULTICAST_BATCH_SIZE) { + await sendBatch(targets.slice(i, i + MULTICAST_BATCH_SIZE)); + } +} + +export const attendanceReminder = onSchedule( + { + schedule: "*/30 10-22 * * *", + timeZone: "Asia/Seoul", + region: "asia-northeast3", + }, + async () => { + await runAttendanceReminder(new Date()); + } +); diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 996a1b9..08be6b3 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -90,6 +90,9 @@ export async function runDailyArchive( const date = overrideDate ?? daysAgoKst(1); logger.info(`dailyArchive start: ${date}`); + let archived = 0; + const judgedUids: string[] = []; + try { const snap = await rtdb.ref("/userVotes").get(); if (!snap.exists()) { logger.info("no userVotes to archive"); @@ -97,8 +100,6 @@ export async function runDailyArchive( } const byUid = snap.val() as Record>; - let archived = 0; - const judgedUids: string[] = []; for (const uid of Object.keys(byUid)) { let dayVotes = byUid[uid]?.[date]; if (!dayVotes) continue; @@ -155,14 +156,15 @@ export async function runDailyArchive( } } + logger.info(`dailyArchive done: ${archived} users archived for ${date}`); + return { date, archived, judgedUids }; + } finally { try { await precomputeScoreboardCache(todayKst()); } catch (err) { logger.error("precomputeScoreboardCache failed", err); } - - logger.info(`dailyArchive done: ${archived} users archived for ${date}`); - return { date, archived, judgedUids }; + } } export const dailyArchive = onSchedule( diff --git a/src/services/attendanceService.ts b/src/services/attendanceService.ts new file mode 100644 index 0000000..792962a --- /dev/null +++ b/src/services/attendanceService.ts @@ -0,0 +1,311 @@ +import type { DecodedIdToken } from "firebase-admin/auth"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../firebase"; +import { HttpError } from "../middleware/errors"; +import { + AttendanceResult, + PointLedgerType, + type AttendanceCheckInResult, + type AttendanceMonth, + type AttendanceMonthDoc, + type PointAward, +} from "../types/panit"; +import { + dayOfWeekKst, + isMonthString, + lastDayOfMonth, + monthOf, + toDateString, +} from "../types/dateString"; +import { + getMonthDoc, + getMonthDocTx, + monthDocRef, +} from "../repositories/attendanceRepository"; +import { + createLedgerEntryTx, + getLatestBalance, + getLatestBalanceTx, +} from "../repositories/pointLedgerRepository"; + +const SKEW_TOLERANCE_MS = 5 * 60 * 1000; +const DAILY_AMOUNT = 10; +const WEEKLY_BONUS_AMOUNT = 50; +const MONTHLY_BONUS_AMOUNT = 100; + +const SLOT_MIN_HOUR = 10; +const SLOT_MAX_HOUR = 22; + +export interface CheckInBody { + clientAttemptedAt?: unknown; + clientIdempotencyKey?: unknown; +} + +// ISO 8601 끝부분의 timezone 표기. `Z`(UTC) 또는 `±HH:MM` / `±HHMM` 둘 다 허용. +// TZ 표기 누락 시 서버 파싱이 UTC로 폴백되어 +9h 어긋남이 발생하므로 강제한다. +const TZ_SUFFIX_PATTERN = /(Z|[+-]\d{2}:?\d{2})$/; + +/** + * 클라가 보낸 ISO 8601 문자열을 절대 시각 Date로 변환한다. + * 반드시 timezone 표기(`Z` 또는 `±HH:MM`)가 포함돼야 한다 — 그래야 + * 서버가 정확한 instant로 해석할 수 있다. + * + * Flutter에서는 `DateTime.now().toUtc().toIso8601String()`을 권장 + * (자동으로 `Z` 접미사가 붙음). + */ +function parseClientAttemptedAt(value: unknown): Date { + if (typeof value !== "string") { + throw new HttpError( + 400, + "clientAttemptedAt must be ISO 8601 string", + "INVALID_INPUT" + ); + } + if (!TZ_SUFFIX_PATTERN.test(value)) { + throw new HttpError( + 400, + "clientAttemptedAt must include timezone (e.g. 'Z' or '+09:00')", + "INVALID_INPUT" + ); + } + const ms = Date.parse(value); + if (Number.isNaN(ms)) { + throw new HttpError( + 400, + "clientAttemptedAt is not a valid ISO 8601 date", + "INVALID_INPUT" + ); + } + return new Date(ms); +} + +function parseIdempotencyKey(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value.length > 128) { + throw new HttpError( + 400, + "clientIdempotencyKey must be a non-empty string ≤128 chars", + "INVALID_INPUT" + ); + } + return value; +} + +/** + * KST 기준 시각을 30분 단위로 round-up하여 "HH:MM" 슬롯 문자열을 만든다. + * 슬롯은 [SLOT_MIN_HOUR:00, SLOT_MAX_HOUR:00] 범위로 클립된다. + * + * 예: 14:23 → "14:30", 22:35 → "22:00"(클립), 09:10 → "10:00"(클립). + */ +export function reminderSlot(serverNow: Date): string { + const kstMs = serverNow.getTime() + 9 * 60 * 60 * 1000; + const utcWallClock = new Date(kstMs); + let hour = utcWallClock.getUTCHours(); + const minute = utcWallClock.getUTCMinutes(); + let slotMinute: number; + if (minute === 0) { + slotMinute = 0; + } else if (minute <= 30) { + slotMinute = 30; + } else { + hour += 1; + slotMinute = 0; + } + if (hour < SLOT_MIN_HOUR) { + hour = SLOT_MIN_HOUR; + slotMinute = 0; + } else if (hour > SLOT_MAX_HOUR || (hour === SLOT_MAX_HOUR && slotMinute > 0)) { + hour = SLOT_MAX_HOUR; + slotMinute = 0; + } + return `${String(hour).padStart(2, "0")}:${String(slotMinute).padStart(2, "0")}`; +} + +/** + * 그 주의 월~토 6일이 모두 days 배열(이번 달)에 있는지 검사. + * today는 일요일이라고 가정 (호출자 책임). + * + * 같은 달 안에 주(월~일)가 온전히 들어 있어야만 true 반환 — 주가 월 경계를 + * 걸치는 경우(예: 6/1 일요일)에는 false. + */ +function isWeekFullyAttended(days: number[], todayDay: number): boolean { + if (todayDay < 7) return false; + const set = new Set(days); + for (let d = todayDay - 6; d < todayDay; d++) { + if (!set.has(d)) return false; + } + return set.has(todayDay); +} + +function sortedInsert(days: number[], day: number): number[] { + const next = [...days, day]; + next.sort((a, b) => a - b); + return next; +} + +export async function checkIn( + token: DecodedIdToken, + body: CheckInBody +): Promise { + const clientAttemptedAt = parseClientAttemptedAt(body.clientAttemptedAt); + const idempotencyKey = parseIdempotencyKey(body.clientIdempotencyKey); + + const serverNowDate = new Date(); + const skewMs = clientAttemptedAt.getTime() - serverNowDate.getTime(); + if (Math.abs(skewMs) > SKEW_TOLERANCE_MS) { + throw new HttpError( + 409, + "client clock differs from server by more than 5 minutes", + "CLOCK_SKEW", + { + serverNow: Timestamp.fromDate(serverNowDate), + clientAttemptedAt: Timestamp.fromDate(clientAttemptedAt), + skewMs, + } + ); + } + + const today = toDateString(serverNowDate); + const month = monthOf(today); + const todayDay = Number(today.slice(8, 10)); + const slot = reminderSlot(serverNowDate); + + const userRef = firestore.collection("users").doc(token.uid); + const monthRef = monthDocRef(token.uid, month); + + return firestore.runTransaction(async (tx) => { + // ── 읽기 단계 (모든 read는 write 전에) ── + const monthDoc = await getMonthDocTx(tx, token.uid, month); + const currentBalance = await getLatestBalanceTx(tx, token.uid); + + // ── 멱등 가드: 같은 idempotencyKey 재호출 → 직전 결과 그대로 ── + if ( + monthDoc?.lastIdempotencyKey === idempotencyKey && + monthDoc.lastResult + ) { + return monthDoc.lastResult; + } + + const days = monthDoc?.days ?? []; + + // ── 이미 출석 가드: 같은 날 다른 키 → alreadyCheckedIn ── + if (days.includes(todayDay)) { + const result: AttendanceCheckInResult = { + result: AttendanceResult.AlreadyCheckedIn, + serverNow: Timestamp.fromDate(serverNowDate), + attendedDays: days, + totalCount: days.length, + pointsAwarded: [], + balanceAfter: currentBalance, + }; + tx.set( + monthRef, + { + lastIdempotencyKey: idempotencyKey, + lastResult: result, + }, + { merge: true } + ); + return result; + } + + // ── 신규 출석 처리 ── + const newDays = sortedInsert(days, todayDay); + const awards: PointAward[] = []; + let runningBalance = currentBalance; + let seq = 0; + + // 1) daily +10 + runningBalance += DAILY_AMOUNT; + awards.push({ type: PointLedgerType.AttendanceDaily, amount: DAILY_AMOUNT }); + createLedgerEntryTx(tx, token.uid, { + type: PointLedgerType.AttendanceDaily, + amount: DAILY_AMOUNT, + balanceAfter: runningBalance, + seq: seq++, + refMonth: month, + refDay: todayDay, + }); + + // 2) weekly_bonus: 일요일 + 그 주 월~토 모두 출석 + if ( + dayOfWeekKst(today) === 0 && + isWeekFullyAttended(newDays, todayDay) + ) { + runningBalance += WEEKLY_BONUS_AMOUNT; + awards.push({ + type: PointLedgerType.AttendanceWeeklyBonus, + amount: WEEKLY_BONUS_AMOUNT, + }); + createLedgerEntryTx(tx, token.uid, { + type: PointLedgerType.AttendanceWeeklyBonus, + amount: WEEKLY_BONUS_AMOUNT, + balanceAfter: runningBalance, + seq: seq++, + refMonth: month, + refDay: todayDay, + }); + } + + // 3) monthly_bonus: 그 달 1~말일 모두 출석 + if (newDays.length === lastDayOfMonth(month)) { + runningBalance += MONTHLY_BONUS_AMOUNT; + awards.push({ + type: PointLedgerType.AttendanceMonthlyBonus, + amount: MONTHLY_BONUS_AMOUNT, + }); + createLedgerEntryTx(tx, token.uid, { + type: PointLedgerType.AttendanceMonthlyBonus, + amount: MONTHLY_BONUS_AMOUNT, + balanceAfter: runningBalance, + seq: seq++, + refMonth: month, + refDay: todayDay, + }); + } + + const result: AttendanceCheckInResult = { + result: AttendanceResult.CheckedIn, + serverNow: Timestamp.fromDate(serverNowDate), + attendedDays: newDays, + totalCount: newDays.length, + pointsAwarded: awards, + balanceAfter: runningBalance, + }; + + const monthPatch: Partial = { + days: newDays, + lastCheckedInAt: Timestamp.fromDate(serverNowDate), + lastIdempotencyKey: idempotencyKey, + lastResult: result, + }; + tx.set(monthRef, monthPatch, { merge: true }); + tx.set(userRef, { attendanceReminderSlot: slot }, { merge: true }); + + return result; + }); +} + +export async function getMonth( + token: DecodedIdToken, + monthRaw: unknown +): Promise { + if (!isMonthString(monthRaw)) { + throw new HttpError( + 400, + "month must be YYYY-MM", + "INVALID_INPUT" + ); + } + const month = monthRaw; + const doc = await getMonthDoc(token.uid, month); + if (!doc) { + throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); + } + const balance = await getLatestBalance(token.uid); + return { + month, + attendedDays: doc.days, + totalCount: doc.days.length, + balance, + }; +} diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 9fe847d..15fd9f8 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -255,12 +255,20 @@ export async function getStats(uid: string, periodParam?: string): Promise | Array | null | undefined; + const normalized: Array = Array.from({length: 7}, (_, i) => w?.[i] ?? null); + return {...stats, weeklyResults: normalized}; } /** diff --git a/src/services/userService.ts b/src/services/userService.ts index 6f12227..b73f667 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -17,7 +17,9 @@ import { } from "../repositories/nicknameRepository"; import { KnowledgeLevel, + NotificationKey, TeamCode, + type NotificationsMap, type Provider, type User, type UserProfile, @@ -268,6 +270,66 @@ export async function updateMe( return toUserProfile(updated!); } +const NOTIFICATION_KEYS = new Set(Object.values(NotificationKey)); + +export interface UpdateNotificationsBody { + notifications?: unknown; +} + +/** + * 알림 설정 부분 업데이트(merge). body.notifications에 들어온 키만 갱신한다. + * + * - 키는 `NotificationKey` whitelist에 있어야 한다 (현재 "attendance"만). + * - 값은 boolean이어야 한다. + * - 미설정 키는 변경하지 않으며 응답에는 병합 후 전체 맵을 돌려준다. + * (미설정/false 모두 푸시 미발송으로 동일 취급되는 opt-in 모델.) + */ +export async function updateNotifications( + token: DecodedIdToken, + body: UpdateNotificationsBody +): Promise { + const raw = body.notifications; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new HttpError( + 400, + "notifications must be an object", + "INVALID_INPUT" + ); + } + + const user = await getUser(token.uid); + if (!user) { + throw new HttpError(404, "user not found", "USER_NOT_FOUND"); + } + + const patch: NotificationsMap = {}; + for (const [key, value] of Object.entries(raw)) { + if (!NOTIFICATION_KEYS.has(key)) { + throw new HttpError( + 400, + `unknown notification key: ${key}`, + "INVALID_INPUT" + ); + } + if (typeof value !== "boolean") { + throw new HttpError( + 400, + `notifications.${key} must be boolean`, + "INVALID_INPUT" + ); + } + patch[key as NotificationKey] = value; + } + + if (Object.keys(patch).length === 0) { + throw new HttpError(400, "no notification keys to update", "INVALID_INPUT"); + } + + const merged: NotificationsMap = { ...(user.notifications ?? {}), ...patch }; + await updateUser(token.uid, { notifications: merged }); + return merged; +} + /** * 닉네임 중복 체크 & 예약. 성공 시 요청 uid로 해당 닉네임을 선점한다. * 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다. diff --git a/src/types/dateString.ts b/src/types/dateString.ts index 2d6b3da..f505e61 100644 --- a/src/types/dateString.ts +++ b/src/types/dateString.ts @@ -67,3 +67,25 @@ export function addDaysKst(date: DateString, delta: number): DateString { const base = startOfDayKst(date).getTime(); return toDateString(new Date(base + delta * 24 * 60 * 60 * 1000)); } + +/** YYYY-MM-DD에서 YYYY-MM 부분만 추출. */ +export function monthOf(date: DateString): string { + return date.slice(0, 7); +} + +const MONTH_PATTERN = /^\d{4}-\d{2}$/; + +/** YYYY-MM 형식 검증. */ +export function isMonthString(value: unknown): value is string { + return typeof value === "string" && MONTH_PATTERN.test(value); +} + +/** YYYY-MM의 말일을 반환 (28/29/30/31). 윤년 포함. */ +export function lastDayOfMonth(month: string): number { + if (!MONTH_PATTERN.test(month)) { + throw new Error(`invalid month (expected YYYY-MM): ${month}`); + } + const [y, m] = month.split("-").map(Number); + // 다음 달 0일 = 이번 달 말일. + return new Date(y, m, 0).getDate(); +} diff --git a/src/types/panit.ts b/src/types/panit.ts index ff52198..25dc471 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -32,6 +32,68 @@ export interface TicketMap { weeklyMaster: number; } +export enum PointLedgerType { + AttendanceDaily = "attendance_daily", + AttendanceWeeklyBonus = "attendance_weekly_bonus", + AttendanceMonthlyBonus = "attendance_monthly_bonus", +} + +export enum AttendanceResult { + CheckedIn = "checkedIn", + AlreadyCheckedIn = "alreadyCheckedIn", +} + +export enum NotificationKey { + Attendance = "attendance", +} + +export type NotificationsMap = Partial>; + +/** + * 단일 재화(`balance`) 변동을 기록하는 ledger row. + * `balanceAfter`가 단일 진실 원천 — 현재 잔액은 가장 최신 row의 `balanceAfter`다. + * 같은 트랜잭션 내 여러 row가 동시 작성될 때는 `seq`로 안정 정렬한다. + */ +export interface PointLedgerEntry { + type: PointLedgerType; + amount: number; + balanceAfter: number; + refMonth?: string; + refDay?: number; + createdAt: Timestamp; + /** 같은 트랜잭션 내 동시 작성 시 안정 정렬용. 0,1,2,... */ + seq: number; +} + +export interface AttendanceMonthDoc { + /** 정렬된 1~말일 배열. */ + days: number[]; + lastCheckedInAt: Timestamp; + lastIdempotencyKey?: string; + lastResult?: AttendanceCheckInResult; +} + +export interface PointAward { + type: PointLedgerType; + amount: number; +} + +export interface AttendanceCheckInResult { + result: AttendanceResult; + serverNow: Timestamp; + attendedDays: number[]; + totalCount: number; + pointsAwarded: PointAward[]; + balanceAfter: number; +} + +export interface AttendanceMonth { + month: string; + attendedDays: number[]; + totalCount: number; + balance: number; +} + export interface User { displayName: string; email: string; @@ -40,6 +102,11 @@ export interface User { favoriteTeamCode?: TeamCode; knowledgeLevel: KnowledgeLevel; createdAt: Timestamp; + notifications?: NotificationsMap; + /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ + fcmToken?: string; + /** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */ + attendanceReminderSlot?: string; /** * 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일. diff --git a/tests/services/attendanceService.test.ts b/tests/services/attendanceService.test.ts new file mode 100644 index 0000000..f5a5351 --- /dev/null +++ b/tests/services/attendanceService.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DecodedIdToken } from "firebase-admin/auth"; +import { firestore } from "../../src/firebase"; +import { checkIn, getMonth } from "../../src/services/attendanceService"; +import { updateNotifications } from "../../src/services/userService"; +import { + AttendanceResult, + PointLedgerType, + type AttendanceMonthDoc, + type PointLedgerEntry, +} from "../../src/types/panit"; + +const uid = "att-user-1"; + +function fakeToken(): DecodedIdToken { + return { + uid, + email: "att@example.com", + firebase: { identities: {}, sign_in_provider: "google.com" }, + aud: "test", + auth_time: 0, + exp: 0, + iat: 0, + iss: "test", + sub: uid, + } as DecodedIdToken; +} + +/** 주어진 KST 날짜+시각의 절대 Date. */ +function kstDate(iso: string): Date { + return new Date(iso); +} + +/** + * KST 시각으로 시스템 클럭을 고정하고 valid한 client body를 생성한다. + * clientAttemptedAt은 UTC ISO 8601 문자열(`Z` 접미사) — Flutter의 + * `DateTime.now().toUtc().toIso8601String()`과 동일 형태. + */ +function bodyAt(now: Date, key = "k1") { + return { + clientAttemptedAt: now.toISOString(), // 항상 'Z' 접미사 포함 + clientIdempotencyKey: key, + }; +} + +async function readLedger(): Promise { + const snap = await firestore + .collection("users") + .doc(uid) + .collection("pointLedger") + .orderBy("createdAt", "asc") + .orderBy("seq", "asc") + .get(); + return snap.docs.map((d) => d.data() as PointLedgerEntry); +} + +async function readMonthDoc(month: string): Promise { + const snap = await firestore + .collection("users") + .doc(uid) + .collection("attendance") + .doc(month) + .get(); + return snap.exists ? (snap.data() as AttendanceMonthDoc) : null; +} + +describe("attendanceService", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users").doc(uid)); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("checkIn", () => { + it("첫 출석 → checkedIn, daily +10, ledger 1건", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + + const result = await checkIn(fakeToken(), bodyAt(now)); + + expect(result.result).toBe(AttendanceResult.CheckedIn); + expect(result.attendedDays).toEqual([12]); + expect(result.totalCount).toBe(1); + expect(result.balanceAfter).toBe(10); + expect(result.pointsAwarded).toEqual([ + { type: PointLedgerType.AttendanceDaily, amount: 10 }, + ]); + + const ledger = await readLedger(); + expect(ledger).toHaveLength(1); + expect(ledger[0].balanceAfter).toBe(10); + expect(ledger[0].refMonth).toBe("2026-05"); + expect(ledger[0].refDay).toBe(12); + + const month = await readMonthDoc("2026-05"); + expect(month?.days).toEqual([12]); + expect(month?.lastIdempotencyKey).toBe("k1"); + }); + + it("같은 idempotencyKey 재호출 → 동일 결과, ledger 추가 X", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + + const r1 = await checkIn(fakeToken(), bodyAt(now, "k1")); + const r2 = await checkIn(fakeToken(), bodyAt(now, "k1")); + + expect(r2).toEqual(r1); + expect(await readLedger()).toHaveLength(1); + }); + + it("같은 날 다른 idempotencyKey → alreadyCheckedIn, 보상 없음", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + + await checkIn(fakeToken(), bodyAt(now, "k1")); + const r2 = await checkIn(fakeToken(), bodyAt(now, "k2")); + + expect(r2.result).toBe(AttendanceResult.AlreadyCheckedIn); + expect(r2.pointsAwarded).toEqual([]); + expect(r2.balanceAfter).toBe(10); + expect(r2.attendedDays).toEqual([12]); + expect(await readLedger()).toHaveLength(1); + }); + + it("TZ 표기 없는 ISO 문자열 → 400 INVALID_INPUT", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + + await expect( + checkIn(fakeToken(), { + clientAttemptedAt: "2026-05-12T14:23:11", // TZ 없음 + clientIdempotencyKey: "k1", + }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("clientAttemptedAt이 서버보다 6분 미래면 409 CLOCK_SKEW + 시각 details", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + const future = new Date(now.getTime() + 6 * 60 * 1000); + + await expect( + checkIn(fakeToken(), bodyAt(future, "k1")) + ).rejects.toMatchObject({ + status: 409, + code: "CLOCK_SKEW", + details: { + skewMs: 6 * 60 * 1000, + }, + }); + }); + + it("월~토 6일 출석 후 일요일 → daily + weekly_bonus, ledger 2건", async () => { + // 2026-05-04(월) ~ 2026-05-09(토) 출석 후 2026-05-10(일). + for (let day = 4; day <= 9; day++) { + const t = kstDate( + `2026-05-${String(day).padStart(2, "0")}T10:00:00+09:00` + ); + vi.setSystemTime(t); + await checkIn(fakeToken(), bodyAt(t, `seed-${day}`)); + } + + const sunday = kstDate("2026-05-10T10:00:00+09:00"); + vi.setSystemTime(sunday); + const result = await checkIn(fakeToken(), bodyAt(sunday, "sun")); + + expect(result.pointsAwarded).toEqual([ + { type: PointLedgerType.AttendanceDaily, amount: 10 }, + { type: PointLedgerType.AttendanceWeeklyBonus, amount: 50 }, + ]); + expect(result.balanceAfter).toBe(7 * 10 + 50); + + const ledger = await readLedger(); + expect(ledger).toHaveLength(8); // 6 daily + (daily + weekly) on Sunday + expect(ledger[ledger.length - 1].type).toBe( + PointLedgerType.AttendanceWeeklyBonus + ); + expect(ledger[ledger.length - 1].balanceAfter).toBe(120); + }); + + it("주가 월 경계를 걸치면 weekly_bonus 미발급 (단순화 정책)", async () => { + // 2026-06-01(월) ~ 2026-06-06(토) 출석 후 2026-06-07(일). + // 6월에 월~토 6일 + 일요일 7일째지만, 정책상 OK이므로 weekly 발급. + // 반례 테스트: 2026-05-25(월)~2026-05-31(일) 한 주는 같은 달 안에 들어가므로 발급. + // 더 명확한 반례: 2026-08-31이 월요일이라면 그 주 일요일은 9월 6일. + // 2026-08-31은 실제로 월요일임. + for (let day = 31; day <= 31; day++) { + const t = kstDate(`2026-08-${day}T10:00:00+09:00`); + vi.setSystemTime(t); + await checkIn(fakeToken(), bodyAt(t, `seed-aug-${day}`)); + } + for (let day = 1; day <= 5; day++) { + const t = kstDate( + `2026-09-${String(day).padStart(2, "0")}T10:00:00+09:00` + ); + vi.setSystemTime(t); + await checkIn(fakeToken(), bodyAt(t, `seed-sep-${day}`)); + } + // 일요일 9/6 + const sunday = kstDate("2026-09-06T10:00:00+09:00"); + vi.setSystemTime(sunday); + const result = await checkIn(fakeToken(), bodyAt(sunday, "sun-cross")); + + // weekly_bonus는 9월 days 안에 1~5만 있으므로 (8/31 월은 9월 doc에 없음) + // isWeekFullyAttended 통과 못 함. + expect( + result.pointsAwarded.find( + (a) => a.type === PointLedgerType.AttendanceWeeklyBonus + ) + ).toBeUndefined(); + }); + + it("그 달 1~말일 모두 출석 → monthly_bonus 추가", async () => { + // 2026-02 (28일). 2/1 ~ 2/27까지 출석한 뒤 2/28에 트리거. + for (let day = 1; day <= 27; day++) { + const t = kstDate( + `2026-02-${String(day).padStart(2, "0")}T10:00:00+09:00` + ); + vi.setSystemTime(t); + await checkIn(fakeToken(), bodyAt(t, `feb-${day}`)); + } + const lastDay = kstDate("2026-02-28T10:00:00+09:00"); + vi.setSystemTime(lastDay); + const result = await checkIn(fakeToken(), bodyAt(lastDay, "feb-28")); + + const monthly = result.pointsAwarded.find( + (a) => a.type === PointLedgerType.AttendanceMonthlyBonus + ); + expect(monthly).toEqual({ + type: PointLedgerType.AttendanceMonthlyBonus, + amount: 100, + }); + // 28 daily + (weekly 발급되는 주 수만큼) + 100 monthly. + // 2026-02 weekly 발급되는 일요일: 2/1, 2/8, 2/15, 2/22 (모두 같은 달, 직전 6일이 1월에 걸쳐있는 2/1만 별도). + // 그러나 2/1은 일요일인데 1/26~1/31이 같은 달이 아니라 weekly 미발급. + // 2/8 발급 (2~7 모두 같은 달), 2/15, 2/22 발급. 2/28(토)는 일요일 아님. + // weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530. + expect(result.balanceAfter).toBe(530); + }); + + it("attendanceReminderSlot이 user doc에 30분 round-up 후 저장된다", async () => { + const now = kstDate("2026-05-12T14:23:11+09:00"); + vi.setSystemTime(now); + + await checkIn(fakeToken(), bodyAt(now)); + + const userSnap = await firestore.collection("users").doc(uid).get(); + expect(userSnap.data()?.attendanceReminderSlot).toBe("14:30"); + }); + + it("늦은 시각(22:35)은 22:00으로 클립된다", async () => { + const now = kstDate("2026-05-12T22:35:00+09:00"); + vi.setSystemTime(now); + await checkIn(fakeToken(), bodyAt(now)); + + const userSnap = await firestore.collection("users").doc(uid).get(); + expect(userSnap.data()?.attendanceReminderSlot).toBe("22:00"); + }); + }); + + describe("getMonth", () => { + it("출석 기록 없는 월 → 404 MONTH_NOT_FOUND", async () => { + await expect(getMonth(fakeToken(), "2026-05")).rejects.toMatchObject({ + status: 404, + code: "MONTH_NOT_FOUND", + }); + }); + + it("형식 오류 → 400 INVALID_INPUT", async () => { + await expect(getMonth(fakeToken(), "2026-5")).rejects.toMatchObject({ + status: 400, + code: "INVALID_INPUT", + }); + }); + + it("정상 월 조회 → days, totalCount, balance 반환", async () => { + const t = kstDate("2026-05-12T10:00:00+09:00"); + vi.setSystemTime(t); + await checkIn(fakeToken(), bodyAt(t)); + vi.useRealTimers(); + + const m = await getMonth(fakeToken(), "2026-05"); + expect(m.month).toBe("2026-05"); + expect(m.attendedDays).toEqual([12]); + expect(m.totalCount).toBe(1); + expect(m.balance).toBe(10); + }); + }); +}); + +describe("userService.updateNotifications", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users").doc(uid)); + await firestore.collection("users").doc(uid).set({ + displayName: "tester", + email: "x@x.com", + provider: "google", + knowledgeLevel: "casual", + }); + }); + + it("attendance:true 토글 → merge 응답", async () => { + const result = await updateNotifications(fakeToken(), { + notifications: { attendance: true }, + }); + expect(result).toEqual({ attendance: true }); + + const user = await firestore.collection("users").doc(uid).get(); + expect(user.data()?.notifications).toEqual({ attendance: true }); + }); + + it("whitelist 외 키 → 400", async () => { + await expect( + updateNotifications(fakeToken(), { + notifications: { unknown: true } as never, + }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("boolean 아닌 값 → 400", async () => { + await expect( + updateNotifications(fakeToken(), { + notifications: { attendance: "yes" } as never, + }) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("notifications 자체가 없으면 → 400", async () => { + await expect( + updateNotifications(fakeToken(), {} as never) + ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); + }); + + it("미존재 유저 → 404", async () => { + await firestore.collection("users").doc(uid).delete(); + await expect( + updateNotifications(fakeToken(), { notifications: { attendance: true } }) + ).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND" }); + }); +});