Implement attendance system with point rewards and push reminders.
- 출석 체크 시 데일리 포인트와 주간/월간 보너스를 지급하며, 멱등성 및 클럭 오차 처리를 포함한 로직을 구현했습니다. - 포인트 이력 관리를 위한 `pointLedger` 컬렉션과 잔액 추적 리포지토리를 추가했습니다. - 사용자별 알림 슬롯에 맞춰 FCM 출석 독려 푸시를 발송하는 스케줄러와 설정 API를 도입했습니다. - RTDB의 희소 배열 응답을 7일 기준 배열로 정규화하여 통계 데이터의 일관성을 강화했습니다. - 출석 시스템 전반에 대한 단위 및 통합 테스트를 추가하여 안정성을 확보했습니다.
This commit is contained in:
parent
e32def7e64
commit
1101aedeb7
@ -63,6 +63,15 @@
|
|||||||
{ "fieldPath": "tierPoints", "order": "ASCENDING" },
|
{ "fieldPath": "tierPoints", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "__name__", "order": "ASCENDING" }
|
{ "fieldPath": "__name__", "order": "ASCENDING" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "pointLedger",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldPath": "createdAt", "order": "DESCENDING" },
|
||||||
|
{ "fieldPath": "seq", "order": "DESCENDING" },
|
||||||
|
{ "fieldPath": "__name__", "order": "DESCENDING" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
|
|||||||
@ -4,12 +4,25 @@ service cloud.firestore {
|
|||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
match /users/{uid} {
|
match /users/{uid} {
|
||||||
allow read: if request.auth != null && request.auth.uid == 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} {
|
match /voteHistory/{date} {
|
||||||
allow read: if request.auth != null && request.auth.uid == uid;
|
allow read: if request.auth != null && request.auth.uid == uid;
|
||||||
allow write: if false;
|
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} {
|
match /games/{gameId} {
|
||||||
|
|||||||
24
src/handlers/attendanceHandlers.ts
Normal file
24
src/handlers/attendanceHandlers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
deleteMe,
|
deleteMe,
|
||||||
getMe,
|
getMe,
|
||||||
updateMe,
|
updateMe,
|
||||||
|
updateNotifications,
|
||||||
} from "../services/userService";
|
} from "../services/userService";
|
||||||
|
|
||||||
export const user = onRequest(async (req, res) => {
|
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 } });
|
res.status(200).json({ user: { uid: token.uid, ...u } });
|
||||||
return;
|
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 === "/") {
|
if (req.method === "DELETE" && req.path === "/") {
|
||||||
const token = await requireAuthToken(req);
|
const token = await requireAuthToken(req);
|
||||||
await deleteMe(token);
|
await deleteMe(token);
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export { prediction } from "./handlers/predictionHandlers";
|
|||||||
export { stats } from "./handlers/statsHandlers";
|
export { stats } from "./handlers/statsHandlers";
|
||||||
export { admin } from "./handlers/adminHandlers";
|
export { admin } from "./handlers/adminHandlers";
|
||||||
export { debug } from "./handlers/debugHandlers";
|
export { debug } from "./handlers/debugHandlers";
|
||||||
|
export { attendance } from "./handlers/attendanceHandlers";
|
||||||
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
||||||
export { dailyArchive } from "./scheduled/dailyArchive";
|
export { dailyArchive } from "./scheduled/dailyArchive";
|
||||||
|
export { attendanceReminder } from "./scheduled/attendanceReminder";
|
||||||
export { onGameCompleted } from "./triggers/onGameCompleted";
|
export { onGameCompleted } from "./triggers/onGameCompleted";
|
||||||
|
|||||||
@ -4,7 +4,8 @@ export class HttpError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
message: string,
|
message: string,
|
||||||
public code?: string
|
public code?: string,
|
||||||
|
public details?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@ -12,9 +13,10 @@ export class HttpError extends Error {
|
|||||||
|
|
||||||
export function sendError(res: express.Response, err: unknown): void {
|
export function sendError(res: express.Response, err: unknown): void {
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
const body = err.code
|
const body: Record<string, unknown> = err.code
|
||||||
? { error: err.code, message: err.message }
|
? { error: err.code, message: err.message }
|
||||||
: { error: err.message };
|
: { error: err.message };
|
||||||
|
if (err.details) Object.assign(body, err.details);
|
||||||
res.status(err.status).json(body);
|
res.status(err.status).json(body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/repositories/attendanceRepository.ts
Normal file
30
src/repositories/attendanceRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
78
src/repositories/pointLedgerRepository.ts
Normal file
78
src/repositories/pointLedgerRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
141
src/scheduled/attendanceReminder.ts
Normal file
141
src/scheduled/attendanceReminder.ts
Normal 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());
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -90,6 +90,9 @@ export async function runDailyArchive(
|
|||||||
const date = overrideDate ?? daysAgoKst(1);
|
const date = overrideDate ?? daysAgoKst(1);
|
||||||
logger.info(`dailyArchive start: ${date}`);
|
logger.info(`dailyArchive start: ${date}`);
|
||||||
|
|
||||||
|
let archived = 0;
|
||||||
|
const judgedUids: string[] = [];
|
||||||
|
try {
|
||||||
const snap = await rtdb.ref("/userVotes").get();
|
const snap = await rtdb.ref("/userVotes").get();
|
||||||
if (!snap.exists()) {
|
if (!snap.exists()) {
|
||||||
logger.info("no userVotes to archive");
|
logger.info("no userVotes to archive");
|
||||||
@ -97,8 +100,6 @@ export async function runDailyArchive(
|
|||||||
}
|
}
|
||||||
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
||||||
|
|
||||||
let archived = 0;
|
|
||||||
const judgedUids: string[] = [];
|
|
||||||
for (const uid of Object.keys(byUid)) {
|
for (const uid of Object.keys(byUid)) {
|
||||||
let dayVotes = byUid[uid]?.[date];
|
let dayVotes = byUid[uid]?.[date];
|
||||||
if (!dayVotes) continue;
|
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 {
|
try {
|
||||||
await precomputeScoreboardCache(todayKst());
|
await precomputeScoreboardCache(todayKst());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("precomputeScoreboardCache failed", err);
|
logger.error("precomputeScoreboardCache failed", err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
|
||||||
return { date, archived, judgedUids };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dailyArchive = onSchedule(
|
export const dailyArchive = onSchedule(
|
||||||
|
|||||||
311
src/services/attendanceService.ts
Normal file
311
src/services/attendanceService.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -255,12 +255,20 @@ export async function getStats(uid: string, periodParam?: string): Promise<Stats
|
|||||||
const cached = await rtdb.ref(cachePath(uid, key)).get();
|
const cached = await rtdb.ref(cachePath(uid, key)).get();
|
||||||
if (cached.exists()) {
|
if (cached.exists()) {
|
||||||
const val = cached.val() as StatsResponse;
|
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);
|
const stats = await computeStats(uid, period);
|
||||||
await rtdb.ref(cachePath(uid, key)).set(stats);
|
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};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -17,7 +17,9 @@ import {
|
|||||||
} from "../repositories/nicknameRepository";
|
} from "../repositories/nicknameRepository";
|
||||||
import {
|
import {
|
||||||
KnowledgeLevel,
|
KnowledgeLevel,
|
||||||
|
NotificationKey,
|
||||||
TeamCode,
|
TeamCode,
|
||||||
|
type NotificationsMap,
|
||||||
type Provider,
|
type Provider,
|
||||||
type User,
|
type User,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
@ -268,6 +270,66 @@ export async function updateMe(
|
|||||||
return toUserProfile(updated!);
|
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로 해당 닉네임을 선점한다.
|
||||||
* 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다.
|
* 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다.
|
||||||
|
|||||||
@ -67,3 +67,25 @@ export function addDaysKst(date: DateString, delta: number): DateString {
|
|||||||
const base = startOfDayKst(date).getTime();
|
const base = startOfDayKst(date).getTime();
|
||||||
return toDateString(new Date(base + delta * 24 * 60 * 60 * 1000));
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,68 @@ export interface TicketMap {
|
|||||||
weeklyMaster: number;
|
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 {
|
export interface User {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -40,6 +102,11 @@ export interface User {
|
|||||||
favoriteTeamCode?: TeamCode;
|
favoriteTeamCode?: TeamCode;
|
||||||
knowledgeLevel: KnowledgeLevel;
|
knowledgeLevel: KnowledgeLevel;
|
||||||
createdAt: Timestamp;
|
createdAt: Timestamp;
|
||||||
|
notifications?: NotificationsMap;
|
||||||
|
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
|
||||||
|
fcmToken?: string;
|
||||||
|
/** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */
|
||||||
|
attendanceReminderSlot?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
|
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
|
||||||
|
|||||||
343
tests/services/attendanceService.test.ts
Normal file
343
tests/services/attendanceService.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user