Implement attendance system with point rewards and push reminders.

- 출석 체크 시 데일리 포인트와 주간/월간 보너스를 지급하며, 멱등성 및 클럭 오차 처리를 포함한 로직을 구현했습니다.
- 포인트 이력 관리를 위한 `pointLedger` 컬렉션과 잔액 추적 리포지토리를 추가했습니다.
- 사용자별 알림 슬롯에 맞춰 FCM 출석 독려 푸시를 발송하는 스케줄러와 설정 API를 도입했습니다.
- RTDB의 희소 배열 응답을 7일 기준 배열로 정규화하여 통계 데이터의 일관성을 강화했습니다.
- 출석 시스템 전반에 대한 단위 및 통합 테스트를 추가하여 안정성을 확보했습니다.
This commit is contained in:
윤정민 2026-05-13 10:51:24 +09:00
parent e32def7e64
commit 1101aedeb7
16 changed files with 1131 additions and 10 deletions

View File

@ -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": []

View File

@ -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} {

View File

@ -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);
}
});

View File

@ -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);

View File

@ -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";

View File

@ -4,7 +4,8 @@ export class HttpError extends Error {
constructor(
public status: number,
message: string,
public code?: string
public code?: string,
public details?: Record<string, unknown>
) {
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<string, unknown> = 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;
}

View File

@ -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<AttendanceMonthDoc | null> {
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<AttendanceMonthDoc | null> {
const snap = await tx.get(monthDocRef(uid, month));
return snap.exists ? (snap.data() as AttendanceMonthDoc) : null;
}

View File

@ -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<number> {
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<number> {
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<string, unknown> = {
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;
}

View File

@ -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<ReminderTarget[]> {
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<User>;
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<void> {
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<void> {
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());
}
);

View File

@ -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<string, Record<string, DayVotes>>;
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(

View File

@ -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<AttendanceCheckInResult> {
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<AttendanceMonthDoc> = {
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<AttendanceMonth> {
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,
};
}

View File

@ -255,12 +255,20 @@ export async function getStats(uid: string, periodParam?: string): Promise<Stats
const cached = await rtdb.ref(cachePath(uid, key)).get();
if (cached.exists()) {
const val = cached.val() as StatsResponse;
if (val.forDate === today) return val;
if (val.forDate === today) return normalizeWeeklyResults(val);
}
const stats = await computeStats(uid, period);
await rtdb.ref(cachePath(uid, key)).set(stats);
return stats;
return normalizeWeeklyResults(stats);
}
// RTDB는 sparse array를 객체로 반환하거나 trailing null을 잘라낸다.
// 응답 직전에 항상 길이 7 배열로 정규화한다.
function normalizeWeeklyResults(stats: StatsResponse): StatsResponse {
const w = stats.weeklyResults as unknown as Record<number, DailyJudgment | null> | Array<DailyJudgment | null> | null | undefined;
const normalized: Array<DailyJudgment | null> = Array.from({length: 7}, (_, i) => w?.[i] ?? null);
return {...stats, weeklyResults: normalized};
}
/**

View File

@ -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<string>(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<NotificationsMap> {
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가 .

View File

@ -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();
}

View File

@ -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<Record<NotificationKey, boolean>>;
/**
* (`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`) .

View File

@ -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<PointLedgerEntry[]> {
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<AttendanceMonthDoc | null> {
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" });
});
});