diff --git a/.env.example b/.env.example index 28c56e4..e7d185c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ DATABASE_SCHEMA=algostep DATABASE_SSL_CA= LOCAL_DATABASE_PATH=.data/postgres GOOGLE_CLIENT_ID= +# Comma-separated Google-verified administrator emails; empty denies all admin access. +ADMIN_EMAILS= SESSION_SECRET=replace-with-at-least-32-random-characters RUNNER_TOKEN=replace-with-a-different-32-character-random-value LLM_PROVIDER=gemini diff --git a/apps/server/src/admin.ts b/apps/server/src/admin.ts new file mode 100644 index 0000000..f752e1e --- /dev/null +++ b/apps/server/src/admin.ts @@ -0,0 +1,346 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import type { DB } from "./db.js"; +import { DomainError, id } from "./domain.js"; +import { businessEvent, visit } from "./business.js"; + +export const kstDate = (at = new Date()) => + new Date(at.getTime() + 9 * 3600000).toISOString().slice(0, 10); +const date = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine( + (v) => + Number.isFinite(Date.parse(v)) && + new Date(v).toISOString().slice(0, 10) === v, + ); +const paging = z.object({ + page: z.coerce.number().int().min(1).max(1000).default(1), +}); +const ticketStatus = z.enum(["OPEN", "IN_PROGRESS", "RESOLVED"]); +const costInput = z + .object({ + sourceKey: z.string().trim().min(1).max(180), + usageDate: date, + service: z.string().trim().min(1).max(100), + currency: z.enum(["KRW", "USD"]), + amount: z.number().finite().min(-1e9).max(1e9), + source: z.string().trim().min(1).max(200), + }) + .strict(); +export function reportRange(query: unknown, now = new Date()) { + const today = kstDate(now); + const v = z + .object({ + from: date.default(today.slice(0, 7) + "-01"), + to: date.default(today), + grain: z.enum(["day", "week", "month"]).default("day"), + }) + .parse(query); + if ( + v.from > v.to || + v.to > today || + (Date.parse(v.to) - Date.parse(v.from)) / 86400000 > 365 + ) + throw new DomainError( + 400, + "INVALID_RANGE", + "조회 기간은 오늘까지, 최대 366일로 선택해 주세요.", + ); + return v; +} + +export async function registerAdmin(app: FastifyInstance, db: DB) { + // Authentication, role checks and CSRF are enforced by the application's + // preHandler for every /api/admin route, including routes added in the future. + app.post("/api/analytics/visit", async (req: any) => { + await visit(db, req.user.id); + return { ok: true }; + }); + app.get("/api/admin/dashboard", async (req) => { + const range = reportRange(req.query); + const args = [range.from, range.to]; + return db.tx(async (tx) => { + // One coherent snapshot for cards, charts and their tabular equivalents. + await tx.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"); + const counts = ( + await tx.query( + `WITH clock AS (SELECT (now() AT TIME ZONE 'Asia/Seoul')::date AS today) + SELECT (SELECT count(DISTINCT user_id)::int FROM visitor_days) AS cumulative, + (SELECT count(DISTINCT user_id)::int FROM visitor_days,clock WHERE day=today) AS daily, + (SELECT count(DISTINCT user_id)::int FROM visitor_days,clock WHERE day>=date_trunc('week',today)::date AND day<=today) AS weekly, + (SELECT count(DISTINCT user_id)::int FROM visitor_days,clock WHERE day>=date_trunc('month',today)::date AND day<=today) AS monthly, + (SELECT count(*)::int FROM users) AS users, + (SELECT count(*)::int FROM users WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul') AS new_users, + (SELECT count(DISTINCT user_id)::int FROM visitor_days WHERE day BETWEEN $1::date AND $2::date) AS period_visitors, + (SELECT count(*)::int FROM support_tickets WHERE status!='RESOLVED') AS open_inquiries, + (SELECT min(first_seen) FROM visitor_days) AS tracking_since`, + args, + ) + ).rows[0]; + const series = ( + await tx.query( + `WITH buckets AS ( + SELECT generate_series(date_trunc($3,$1::date),date_trunc($3,$2::date),('1 ' || $3)::interval)::date AS bucket + ), visitors AS ( + SELECT date_trunc($3,day)::date AS bucket,count(DISTINCT user_id)::int AS visitors FROM visitor_days WHERE day BETWEEN $1::date AND $2::date GROUP BY 1 + ), joined AS ( + SELECT date_trunc($3,created_at AT TIME ZONE 'Asia/Seoul')::date AS bucket,count(*)::int AS new_users FROM users + WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY 1 + ) SELECT to_char(b.bucket,'YYYY-MM-DD') AS period,CASE WHEN (SELECT min(day) FROM visitor_days) IS NULL OR (b.bucket+('1 ' || $3)::interval)::date <= (SELECT min(day) FROM visitor_days) THEN NULL ELSE coalesce(v.visitors,0)::int END AS visitors,coalesce(j.new_users,0)::int AS new_users + FROM buckets b LEFT JOIN visitors v USING(bucket) LEFT JOIN joined j USING(bucket) ORDER BY b.bucket`, + [...args, range.grain], + ) + ).rows; + const costs = ( + await tx.query( + `SELECT currency,sum(amount)::text AS amount,count(*)::int AS entries,max(updated_at) AS updated_at FROM cost_entries WHERE usage_date BETWEEN $1::date AND $2::date GROUP BY currency`, + args, + ) + ).rows; + const revenue = ( + await tx.query( + `SELECT currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE 0 END)::text AS gross,sum(CASE WHEN kind='REFUND' THEN amount ELSE 0 END)::text AS refunds,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS net FROM revenue_entries WHERE occurred_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY currency`, + args, + ) + ).rows; + const costSeries = ( + await tx.query( + `SELECT to_char(date_trunc($3,usage_date),'YYYY-MM-DD') AS period,currency,sum(amount)::text AS amount FROM cost_entries WHERE usage_date BETWEEN $1::date AND $2::date GROUP BY 1,2 ORDER BY 1,2`, + [...args, range.grain], + ) + ).rows; + const revenueSeries = ( + await tx.query( + `SELECT to_char(date_trunc($3,occurred_at AT TIME ZONE 'Asia/Seoul'),'YYYY-MM-DD') AS period,currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS amount FROM revenue_entries WHERE occurred_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY 1,2 ORDER BY 1,2`, + [...args, range.grain], + ) + ).rows; + const usage = ( + await tx.query( + `SELECT provider,model,count(*)::int AS calls,count(input_tokens)::int AS measured_calls,sum(input_tokens)::text AS input_tokens,sum(output_tokens)::text AS output_tokens,sum(thinking_tokens)::text AS thinking_tokens FROM review_api_usage WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY provider,model`, + args, + ) + ).rows; + const events = ( + await tx.query( + `SELECT type,count(*)::int AS count FROM business_events WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY type ORDER BY count DESC`, + args, + ) + ).rows; + return { + range, + timezone: "Asia/Seoul", + counts, + series, + costs, + costSeries, + revenue, + revenueSeries, + usage, + events, + paymentEnabled: false, + costSource: "imported_actuals", + generatedAt: new Date().toISOString(), + }; + }); + }); + app.get("/api/admin/users", async (req) => { + const { page } = paging.parse(req.query); + const rows = ( + await db.query( + `SELECT u.id,u.email,u.display_name,u.created_at,(SELECT max(day)::text FROM visitor_days v WHERE v.user_id=u.id) AS last_visit FROM users u ORDER BY u.created_at DESC,u.id LIMIT 26 OFFSET $1`, + [(page - 1) * 25], + ) + ).rows; + return { rows: rows.slice(0, 25), hasMore: rows.length > 25 }; + }); + app.get("/api/admin/events", async (req) => { + const { page } = paging.parse(req.query); + const range = reportRange(req.query); + const { type } = z + .object({ + type: z + .string() + .regex(/^[A-Z_]{1,60}$/) + .optional(), + }) + .parse(req.query); + const rows = ( + await db.query( + `SELECT id,type,actor_id,entity_id,metadata,created_at FROM business_events WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' AND ($3::text IS NULL OR type=$3) ORDER BY created_at DESC,id LIMIT 51 OFFSET $4`, + [range.from, range.to, type ?? null, (page - 1) * 50], + ) + ).rows; + return { rows: rows.slice(0, 50), hasMore: rows.length > 50 }; + }); + const listTickets = async (req: any, admin: boolean) => { + const { page } = paging.parse(req.query); + const { status } = z + .object({ status: ticketStatus.optional() }) + .parse(req.query); + const rows = ( + await db.query( + `SELECT t.*,${admin ? "u.email,u.display_name," : ""}count(*) OVER()::int AS total FROM support_tickets t JOIN users u ON u.id=t.user_id WHERE ($1::uuid IS NULL OR t.user_id=$1) AND ($2::text IS NULL OR t.status=$2) ORDER BY t.created_at DESC,t.id LIMIT 26 OFFSET $3`, + [admin ? null : req.user.id, status ?? null, (page - 1) * 25], + ) + ).rows; + return { rows: rows.slice(0, 25), hasMore: rows.length > 25 }; + }; + app.get("/api/support", (req) => listTickets(req, false)); + app.get("/api/admin/inquiries", (req) => listTickets(req, true)); + app.post("/api/support", async (req: any) => { + const v = z + .object({ + requestKey: z.string().min(8).max(100), + category: z.enum(["question", "bug", "billing", "other"]), + subject: z.string().trim().min(1).max(120), + body: z.string().trim().min(1).max(5000), + }) + .strict() + .parse(req.body); + return db.tx(async (tx) => { + await tx.query("SELECT id FROM users WHERE id=$1 FOR UPDATE", [ + req.user.id, + ]); + const old = ( + await tx.query( + "SELECT * FROM support_tickets WHERE user_id=$1 AND request_key=$2", + [req.user.id, v.requestKey], + ) + ).rows[0]; + if (old) return old; + const count = ( + await tx.query( + "SELECT count(*)::int AS n FROM support_tickets WHERE user_id=$1 AND created_at>now()-interval '24 hours'", + [req.user.id], + ) + ).rows[0].n; + if (count >= 10) + throw new DomainError( + 429, + "INQUIRY_LIMIT", + "문의는 24시간 동안 최대 10건까지 접수할 수 있어요.", + ); + const ticket = ( + await tx.query( + "INSERT INTO support_tickets(id,user_id,request_key,category,subject,body) VALUES($1,$2,$3,$4,$5,$6) RETURNING *", + [id(), req.user.id, v.requestKey, v.category, v.subject, v.body], + ) + ).rows[0]; + await businessEvent( + tx, + "INQUIRY_CREATED", + req.user.id, + ticket.id, + "inquiry:" + ticket.id, + { category: v.category }, + ); + return ticket; + }); + }); + app.patch("/api/admin/inquiries/:id", async (req: any) => { + const ticketId = z.string().uuid().parse(req.params.id); + const v = z + .object({ + status: ticketStatus, + reply: z.string().trim().max(5000), + revision: z.number().int().nonnegative(), + }) + .strict() + .parse(req.body); + if (v.status === "RESOLVED" && !v.reply) + throw new DomainError( + 400, + "REPLY_REQUIRED", + "답변을 작성한 뒤 완료 처리해 주세요.", + ); + return db.tx(async (tx) => { + const ticket = ( + await tx.query( + "UPDATE support_tickets SET status=$2,reply=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$4 RETURNING *", + [ticketId, v.status, v.reply, v.revision], + ) + ).rows[0]; + if (!ticket) + throw new DomainError( + 409, + "INQUIRY_CONFLICT", + "문의가 변경됐어요. 목록을 새로고침해 주세요.", + ); + await businessEvent( + tx, + "INQUIRY_UPDATED", + req.user.id, + ticketId, + `inquiry:${ticketId}:${ticket.revision}`, + { status: v.status }, + ); + return ticket; + }); + }); + app.get("/api/admin/costs", async (req) => { + const range = reportRange(req.query); + const { page } = paging.parse(req.query); + const rows = ( + await db.query( + `SELECT id,source_key,to_char(usage_date,'YYYY-MM-DD') AS usage_date,service,currency,amount,source,updated_at FROM cost_entries WHERE usage_date BETWEEN $1::date AND $2::date ORDER BY cost_entries.usage_date DESC,source_key LIMIT 51 OFFSET $3`, + [range.from, range.to, (page - 1) * 50], + ) + ).rows; + return { rows: rows.slice(0, 50), hasMore: rows.length > 50 }; + }); + const importCosts = async (req: any) => { + const { entries } = z + .object({ entries: z.array(costInput).min(1).max(500) }) + .strict() + .parse(req.body); + if ( + entries.some( + (e) => + e.sourceKey.startsWith("REPLACE_") || + e.service.startsWith("REPLACE_") || + e.source.startsWith("REPLACE_") || + e.usageDate > kstDate(), + ) + ) + throw new DomainError( + 400, + "INVALID_COST_SOURCE", + "예제 값을 실제 청구 자료로 바꾸고 사용일을 확인해 주세요. 미래 비용은 등록할 수 없어요.", + ); + if (new Set(entries.map((e) => e.sourceKey)).size !== entries.length) + throw new DomainError( + 400, + "DUPLICATE_SOURCE", + "한 가져오기에 중복된 원본 키가 있어요.", + ); + await db.tx(async (tx) => { + for (const e of entries) { + await tx.query( + `INSERT INTO cost_entries(id,source_key,usage_date,service,currency,amount,source) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(source_key) DO UPDATE SET usage_date=excluded.usage_date,service=excluded.service,currency=excluded.currency,amount=excluded.amount,source=excluded.source,updated_at=now()`, + [ + id(), + e.sourceKey, + e.usageDate, + e.service, + e.currency, + e.amount, + e.source, + ], + ); + } + await businessEvent( + tx, + "COSTS_IMPORTED", + req.user?.id ?? null, + null, + "cost-import:" + id(), + { entries: entries.length }, + ); + }); + return { imported: entries.length }; + }; + app.post("/api/admin/costs/import", importCosts); + app.post("/api/operations/costs/import", importCosts); +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 5e74891..83ea2a7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -23,6 +23,8 @@ import { languages } from "./content.js"; import { renderPdf } from "./pdf.js"; import { externalize, hydrate, type ObjectStorage } from "./object-storage.js"; import { maintenance } from "./maintenance.js"; +import { registerAdmin } from "./admin.js"; +import { businessEvent, visit } from "./business.js"; type Config = { db: DB; @@ -36,6 +38,7 @@ type Config = { operationsToken?: string; schedulerAudience?: string; schedulerEmail?: string; + adminEmails?: string[]; verifyGoogle?: ( token: string, ) => Promise<{ sub: string; email: string; name: string; nonce?: string }>; @@ -81,6 +84,11 @@ const resultInput = z.object({ runnerImage: z.string().max(256), }); export async function createApp(c: Config) { + const adminEmails = new Set( + (c.adminEmails ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean), + ); + const isAdmin = (account: any) => + adminEmails.has(account.email.toLowerCase()); const app = Fastify({ logger: process.env.NODE_ENV !== "test", bodyLimit: 2 * 1024 * 1024, @@ -213,9 +221,17 @@ export async function createApp(c: Config) { if (!rows[0]) throw new DomainError(401, "SESSION_EXPIRED", "다시 로그인해 주세요."); req.user = rows[0]; + if (rawPath.startsWith("/api/admin/") && !isAdmin(req.user)) + throw new DomainError( + 403, + "ADMIN_REQUIRED", + "관리자만 이용할 수 있어요.", + ); if ( rows[0].client === "android" && - !/^\/api\/(me|topics|quiz\/[^/?]+|auth\/logout)(\?|$)/.test(req.url) + !/^\/api\/(me|topics|quiz\/[^/?]+|auth\/logout|analytics\/visit|support)(\?|$)/.test( + req.url, + ) ) throw new DomainError( 403, @@ -232,6 +248,7 @@ export async function createApp(c: Config) { "화면을 다시 열고 시도해 주세요.", ); }); + await registerAdmin(app, c.db); const user = (req: any) => req.user.id as string; app.get("/api/operations/metrics", async () => ({ reviewApiUsage30Days: ( @@ -341,14 +358,28 @@ export async function createApp(c: Config) { "로그인 요청이 만료됐어요.", ); } + const newAccountId = id(); const { rows } = await db.query( "INSERT INTO users(id,google_subject,email,display_name) VALUES($1,$2,$3,$4) ON CONFLICT(google_subject) DO UPDATE SET email=excluded.email,display_name=excluded.display_name RETURNING *", - [id(), p.sub, p.email, p.name], + [newAccountId, p.sub, p.email, p.name], ); + if (rows[0].id === newAccountId) + await businessEvent( + db, + "USER_REGISTERED", + rows[0].id, + rows[0].id, + "registration:" + rows[0].id, + { client }, + ); await db.query( "INSERT INTO sessions(id,user_id,csrf,expires_at,client) VALUES($1,$2,$3,now()+interval '7 days',$4)", [sessionDigest(token), rows[0].id, csrf, client], ); + await visit(db, rows[0].id); + await businessEvent(db, "LOGIN", rows[0].id, null, "login:" + id(), { + client, + }); return rows[0]; }); reply.setCookie("session", token, { @@ -358,17 +389,21 @@ export async function createApp(c: Config) { path: "/", maxAge: 7 * 86400, }); - return { user: account, csrf }; + return { user: { ...account, isAdmin: isAdmin(account) }, csrf }; }, ); - app.get("/api/me", async (req: any) => ({ - user: { - id: req.user.id, - name: req.user.display_name, - email: req.user.email, - }, - csrf: req.user.csrf, - })); + app.get("/api/me", async (req: any) => { + await visit(c.db, req.user.id); + return { + user: { + id: req.user.id, + name: req.user.display_name, + email: req.user.email, + isAdmin: isAdmin(req.user), + }, + csrf: req.user.csrf, + }; + }); app.post("/api/auth/logout", async (req: any, reply) => { await c.db.query("DELETE FROM sessions WHERE id=$1", [ sessionDigest(req.cookies.session), @@ -388,59 +423,69 @@ export async function createApp(c: Config) { return { ...t, quiz: quizzes[0], quizzes }; }), })); - app.post("/api/quiz/:topic", async (req: any) => { - const v = z - .object({ - answer: z.number().int().min(0).max(10), - requestKey: key, - questionId: z.string().max(80).default("core"), - }) - .parse(req.body); - const { rows } = await c.db.query("SELECT * FROM topics WHERE id=$1", [ - req.params.topic, - ]); - if (!rows[0]) - throw new DomainError(404, "NOT_FOUND", "학습 내용을 찾을 수 없어요."); - const q = topicQuizzes(rows[0] as any).find((q) => q.id === v.questionId); - if (!q) throw new DomainError(404, "NOT_FOUND", "문항을 찾을 수 없어요."); - if (v.answer >= q.options.length) - throw new DomainError(400, "INVALID_INPUT", "선택지를 확인해 주세요."); - const existing = await c.db.query( - "SELECT * FROM quiz_attempts WHERE user_id=$1 AND request_key=$2", - [user(req), v.requestKey], - ); - if (existing.rows[0]) { - const old = existing.rows[0]; - return { - correct: old.correct, - answer: old.question_snapshot.answer, - explanation: old.question_snapshot.explanation, - }; - } - await c.db.query( - "INSERT INTO quiz_attempts(id,user_id,topic_id,answer,correct,request_key,question_snapshot) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING", - [ - id(), - user(req), + app.post("/api/quiz/:topic", async (req: any) => + c.db.tx(async (db) => { + const v = z + .object({ + answer: z.number().int().min(0).max(10), + requestKey: key, + questionId: z.string().max(80).default("core"), + }) + .parse(req.body); + const { rows } = await db.query("SELECT * FROM topics WHERE id=$1", [ req.params.topic, - v.answer, - v.answer === q.answer, - v.requestKey, - JSON.stringify(q), - ], - ); - const persisted = ( - await c.db.query( + ]); + if (!rows[0]) + throw new DomainError(404, "NOT_FOUND", "학습 내용을 찾을 수 없어요."); + const q = topicQuizzes(rows[0] as any).find((q) => q.id === v.questionId); + if (!q) throw new DomainError(404, "NOT_FOUND", "문항을 찾을 수 없어요."); + if (v.answer >= q.options.length) + throw new DomainError(400, "INVALID_INPUT", "선택지를 확인해 주세요."); + const existing = await db.query( "SELECT * FROM quiz_attempts WHERE user_id=$1 AND request_key=$2", [user(req), v.requestKey], - ) - ).rows[0]; - return { - correct: persisted.correct, - answer: persisted.question_snapshot.answer, - explanation: persisted.question_snapshot.explanation, - }; - }); + ); + if (existing.rows[0]) { + const old = existing.rows[0]; + return { + correct: old.correct, + answer: old.question_snapshot.answer, + explanation: old.question_snapshot.explanation, + }; + } + await db.query( + "INSERT INTO quiz_attempts(id,user_id,topic_id,answer,correct,request_key,question_snapshot) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING", + [ + id(), + user(req), + req.params.topic, + v.answer, + v.answer === q.answer, + v.requestKey, + JSON.stringify(q), + ], + ); + const persisted = ( + await db.query( + "SELECT * FROM quiz_attempts WHERE user_id=$1 AND request_key=$2", + [user(req), v.requestKey], + ) + ).rows[0]; + await businessEvent( + db, + "QUIZ_ANSWERED", + user(req), + persisted.id, + "quiz:" + persisted.id, + { correct: persisted.correct, topic: req.params.topic }, + ); + return { + correct: persisted.correct, + answer: persisted.question_snapshot.answer, + explanation: persisted.question_snapshot.explanation, + }; + }), + ); app.get("/api/problems", async () => ({ problems: (await c.db.query("SELECT * FROM problems ORDER BY id")).rows, })); @@ -479,6 +524,14 @@ export async function createApp(c: Config) { "INSERT INTO records(id,user_id,problem_id,language,source) VALUES($1,$2,$3,$4,$5) RETURNING *", [id(), user(req), p.id, input.language, p.starters[input.language]], ); + await businessEvent( + db, + "PRACTICE_STARTED", + user(req), + rows[0].id, + "practice:" + rows[0].id, + { problem: p.id, language: input.language }, + ); return rows[0]; }); }); @@ -563,6 +616,14 @@ export async function createApp(c: Config) { "UPDATE records SET latest_execution_id=$1,updated_at=now() WHERE id=$2", [run, r.id], ); + await businessEvent( + db, + "EXECUTION_REQUESTED", + user(req), + run, + "execution-request:" + run, + { mode: v.mode, language: r.language, problem: r.problem_id }, + ); return (await db.query("SELECT * FROM executions WHERE id=$1", [run])) .rows[0]; }); @@ -645,6 +706,14 @@ export async function createApp(c: Config) { "INSERT INTO review_credit_events(id,allowance_id,review_id,request_ref,type) VALUES($1,$2,$3,$3,'RESERVE')", [id(), a.id, rid], ); + await businessEvent( + db, + "REVIEW_REQUESTED", + user(req), + rid, + "review-request:" + rid, + { language: r.language }, + ); return (await db.query("SELECT * FROM reviews WHERE id=$1", [rid])) .rows[0]; }); @@ -789,7 +858,7 @@ export async function createApp(c: Config) { return c.db.tx(async (db) => { const e = ( await db.query( - "SELECT e.*,r.problem_id FROM executions e JOIN records r ON r.id=e.record_id WHERE e.id=$1 AND e.lease_token=$2 AND e.status='RUNNING' AND e.lease_expires_at>now() AND r.expires_at>now() FOR UPDATE OF e", + "SELECT e.*,r.problem_id,r.user_id FROM executions e JOIN records r ON r.id=e.record_id WHERE e.id=$1 AND e.lease_token=$2 AND e.status='RUNNING' AND e.lease_expires_at>now() AND r.expires_at>now() FOR UPDATE OF e", [uuid.parse(req.params.id), v.token], ) ).rows[0]; @@ -818,6 +887,17 @@ export async function createApp(c: Config) { "UPDATE executions SET status=$2,result=$3,finished_at=now(),lease_token=NULL WHERE id=$1", [e.id, v.systemError ? "FAILED" : "SUCCEEDED", JSON.stringify(result)], ); + await businessEvent( + db, + "EXECUTION_FINISHED", + e.user_id, + e.id, + "execution-finish:" + e.id, + { + status: v.systemError ? "FAILED" : "SUCCEEDED", + verdict: v.result?.verdict ?? "SYSTEM_ERROR", + }, + ); return { accepted: true }; }); }); diff --git a/apps/server/src/business.ts b/apps/server/src/business.ts new file mode 100644 index 0000000..ccdeb61 --- /dev/null +++ b/apps/server/src/business.ts @@ -0,0 +1,25 @@ +import type { DB } from "./db.js"; +import { randomUUID } from "node:crypto"; + +// Only purpose-selected non-content metadata belongs here: never code, prompts, +// tokens, passwords, request bodies or inquiry contents. +export async function businessEvent( + db: DB, + type: string, + actor: string | null, + entity: string | null, + key: string, + metadata: Record = {}, +) { + await db.query( + "INSERT INTO business_events(id,event_key,type,actor_id,entity_id,metadata) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_key) DO NOTHING", + [randomUUID(), key, type, actor, entity, JSON.stringify(metadata)], + ); +} + +export async function visit(db: DB, user: string) { + await db.query( + "INSERT INTO visitor_days(user_id,day) VALUES($1,(now() AT TIME ZONE 'Asia/Seoul')::date) ON CONFLICT DO NOTHING", + [user], + ); +} diff --git a/apps/server/src/domain.ts b/apps/server/src/domain.ts index b0dc3b5..8d4b863 100644 --- a/apps/server/src/domain.ts +++ b/apps/server/src/domain.ts @@ -1,6 +1,7 @@ import { randomUUID, createHash } from "node:crypto"; import type { DB } from "./db.js"; import { equivalent, staticFindings } from "./static-analysis.js"; +import { businessEvent } from "./business.js"; export const id = randomUUID; export const hash = (value: string) => createHash("sha256").update(value).digest("hex"); @@ -57,8 +58,27 @@ export async function releaseReview(db: DB, review: any, reason: string) { "INSERT INTO review_credit_events(id,allowance_id,review_id,request_ref,type) VALUES($1,$2,$3,$3,'RELEASE')", [id(), review.allowance_id, review.id], ); + await businessEvent( + db, + "REVIEW_FAILED", + review.user_id, + review.id, + "review-terminal:" + review.id, + { reason }, + ); } export async function deleteRecord(db: DB, record: string) { + const owner = ( + await db.query("SELECT user_id FROM records WHERE id=$1", [record]) + ).rows[0]; + if (owner) + await businessEvent( + db, + "RECORD_DELETED", + owner.user_id, + record, + "record-deleted:" + record, + ); for (const row of ( await db.query( "SELECT result->'traceObject'->>'key' AS key FROM executions WHERE record_id=$1 AND result ? 'traceObject'", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 482137f..4f66808 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,8 +9,13 @@ import { GcsStorage } from "./object-storage.js"; import { maintenance } from "./maintenance.js"; import staticFiles from "@fastify/static"; const production = process.env.NODE_ENV === "production"; -if(production && process.env.OPERATIONS_TOKEN && (process.env.OPERATIONS_TOKEN.length<32 || process.env.OPERATIONS_TOKEN.startsWith('replace-'))) - throw new Error('Use a strong OPERATIONS_TOKEN.'); +if ( + production && + process.env.OPERATIONS_TOKEN && + (process.env.OPERATIONS_TOKEN.length < 32 || + process.env.OPERATIONS_TOKEN.startsWith("replace-")) +) + throw new Error("Use a strong OPERATIONS_TOKEN."); if ( production && (!process.env.DATABASE_URL || @@ -46,6 +51,7 @@ const app = await createApp({ operationsToken: process.env.OPERATIONS_TOKEN, schedulerAudience: process.env.SCHEDULER_AUDIENCE, schedulerEmail: process.env.SCHEDULER_EMAIL, + adminEmails: (process.env.ADMIN_EMAILS ?? "").split(","), }); if (process.env.WEB_DIST) await app.register(staticFiles, { diff --git a/apps/server/src/migrations.ts b/apps/server/src/migrations.ts index f6c9ef5..284a861 100644 --- a/apps/server/src/migrations.ts +++ b/apps/server/src/migrations.ts @@ -30,6 +30,22 @@ const migrations = [ CREATE TABLE fleet_controller(id integer PRIMARY KEY CHECK(id=1),token uuid,expires_at timestamptz); INSERT INTO fleet_controller(id) VALUES(1);`, }, + { + version: 5, + sql: ` + CREATE TABLE visitor_days(user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE,day date NOT NULL,first_seen timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(user_id,day)); + CREATE INDEX visitor_days_day ON visitor_days(day); + CREATE TABLE business_events(id uuid PRIMARY KEY,event_key text UNIQUE NOT NULL,type text NOT NULL,actor_id uuid REFERENCES users ON DELETE SET NULL,entity_id text,metadata jsonb NOT NULL DEFAULT '{}',created_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX business_events_created ON business_events(created_at DESC); + CREATE INDEX business_events_type_created ON business_events(type,created_at); + CREATE TABLE support_tickets(id uuid PRIMARY KEY,user_id uuid NOT NULL REFERENCES users,request_key text NOT NULL,category text NOT NULL CHECK(category IN ('question','bug','billing','other')),subject text NOT NULL,body text NOT NULL,status text NOT NULL DEFAULT 'OPEN' CHECK(status IN ('OPEN','IN_PROGRESS','RESOLVED')),reply text NOT NULL DEFAULT '',revision integer NOT NULL DEFAULT 0,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),UNIQUE(user_id,request_key)); + CREATE INDEX support_tickets_status ON support_tickets(status,created_at DESC); + CREATE TABLE cost_entries(id uuid PRIMARY KEY,source_key text UNIQUE NOT NULL,usage_date date NOT NULL,service text NOT NULL,currency text NOT NULL CHECK(currency IN ('KRW','USD')),amount numeric(18,6) NOT NULL,source text NOT NULL,updated_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX cost_entries_date ON cost_entries(usage_date); + CREATE TABLE revenue_entries(id uuid PRIMARY KEY,provider_event_id text UNIQUE NOT NULL,kind text NOT NULL CHECK(kind IN ('PAYMENT','REFUND')),currency text NOT NULL CHECK(currency IN ('KRW','USD')),amount numeric(18,6) NOT NULL CHECK(amount>0),occurred_at timestamptz NOT NULL); + CREATE INDEX revenue_entries_date ON revenue_entries(occurred_at); + `, + }, ]; export async function migrate(db: DB) { diff --git a/apps/server/src/review-worker.ts b/apps/server/src/review-worker.ts index 43e2eb3..e570048 100644 --- a/apps/server/src/review-worker.ts +++ b/apps/server/src/review-worker.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { requestReview, type LlmOptions } from "./llm.js"; import type { DB } from "./db.js"; import { id, releaseReview } from "./domain.js"; +import { businessEvent } from "./business.js"; const resultSchema = z.object({ logicalErrors: z.string().min(1).max(15000), efficiencyImprovements: z.string().min(1).max(15000), @@ -76,6 +77,14 @@ export async function processReview(db: DB, options: LlmOptions) { "INSERT INTO review_credit_events(id,allowance_id,review_id,request_ref,type) VALUES($1,$2,$3,$3,'CONSUME')", [id(), task.allowance_id, task.id], ); + await businessEvent( + tx, + "REVIEW_SUCCEEDED", + task.user_id, + task.id, + "review-terminal:" + task.id, + { model: options.model }, + ); }); } catch { await db.tx((tx) => releaseReview(tx, task, "REVIEW_SERVICE_FAILED")); diff --git a/apps/server/test/admin.test.ts b/apps/server/test/admin.test.ts new file mode 100644 index 0000000..297a90d --- /dev/null +++ b/apps/server/test/admin.test.ts @@ -0,0 +1,389 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { database, type DB } from "../src/db.js"; +import { createApp } from "../src/app.js"; +import { kstDate, reportRange } from "../src/admin.js"; +import { businessEvent } from "../src/business.js"; +import { seed } from "../src/content.js"; +import { id } from "../src/domain.js"; +process.env.NODE_ENV = "test"; +let db: DB, app: Awaited>; +const origin = "http://localhost:5173"; +type Login = { cookie: string; csrf: string; id: string }; +let admin: Login, student: Login, other: Login; +async function login(name: string) { + const r = await app.inject({ + method: "POST", + url: "/api/auth/google", + headers: { origin }, + payload: { credential: "test-token-" + name }, + }); + assert.equal(r.statusCode, 200, r.body); + return { + cookie: String(r.headers["set-cookie"]).split(";")[0], + csrf: r.json().csrf, + id: r.json().user.id, + }; +} +const request = (who: Login, method: any, url: string, payload?: any) => + app.inject({ + method, + url, + payload, + headers: { origin, cookie: who.cookie, "x-csrf-token": who.csrf }, + }); +before(async () => { + db = await database(); + await seed(db); + app = await createApp({ + db, + origin, + googleClientId: "test", + sessionSecret: "test", + runnerToken: "runner", + llmEnabled: false, + adminEmails: ["admin@example.test"], + operationsToken: "operations", + verifyGoogle: async (token) => ({ + sub: token, + email: token.replace("test-token-", "") + "@example.test", + name: token, + }), + }); + admin = await login("admin"); + student = await login("student"); + other = await login("other"); +}); +after(async () => { + await app.close(); + await db.close(); +}); +test("admin APIs require a verified allowlisted session and CSRF", async () => { + assert.equal((await app.inject("/api/admin/dashboard")).statusCode, 401); + for (const path of [ + "/dashboard", + "/users", + "/events", + "/inquiries", + "/costs", + ]) + assert.equal( + (await request(student, "GET", "/api/admin" + path)).statusCode, + 403, + ); + assert.equal( + (await request(student, "POST", "/api/admin/costs/import", { entries: [] })) + .statusCode, + 403, + ); + assert.equal( + (await request(student, "PATCH", "/api/admin/inquiries/" + id(), {})) + .statusCode, + 403, + ); + assert.equal( + ( + await app.inject({ + method: "POST", + url: "/api/admin/costs/import", + headers: { origin, cookie: admin.cookie }, + payload: { entries: [] }, + }) + ).statusCode, + 403, + ); + assert.equal( + (await request(admin, "GET", "/api/me")).json().user.isAdmin, + true, + ); + assert.equal( + (await request(student, "GET", "/api/me")).json().user.isAdmin, + false, + ); + assert.equal( + (await request(admin, "GET", "/api/admin/users")).json().rows.length, + 3, + ); +}); +test("KST calendar boundaries and date validation are explicit", () => { + assert.equal(kstDate(new Date("2026-09-30T14:59:59Z")), "2026-09-30"); + assert.equal(kstDate(new Date("2026-09-30T15:00:00Z")), "2026-10-01"); + assert.deepEqual(reportRange({}, new Date("2026-09-30T15:00:00Z")), { + from: "2026-10-01", + to: "2026-10-01", + grain: "day", + }); + assert.throws(() => reportRange({ from: "2026-02-30" })); + assert.throws(() => reportRange({ from: "2025-01-01", to: "2026-09-19" })); + assert.throws(() => reportRange({ from: "2026-09-19", to: "2026-09-18" })); +}); +test("visits deduplicate across tabs and sessions; period buckets count distinct users", async () => { + for (let i = 0; i < 3; i++) + assert.equal( + (await request(student, "POST", "/api/analytics/visit", {})).statusCode, + 200, + ); + await login("student"); + assert.equal( + ( + await db.query( + "SELECT count(*)::int AS n FROM visitor_days WHERE user_id=$1", + [student.id], + ) + ).rows[0].n, + 1, + ); + await db.query( + "INSERT INTO visitor_days(user_id,day) VALUES($1,((now() AT TIME ZONE 'Asia/Seoul')::date-1)) ON CONFLICT DO NOTHING", + [student.id], + ); + const today = kstDate(), + from = new Date(Date.parse(today) - 86400000).toISOString().slice(0, 10); + for (const grain of ["day", "week", "month"]) { + const r = await request( + admin, + "GET", + `/api/admin/dashboard?from=${from}&to=${today}&grain=${grain}`, + ); + assert.equal(r.statusCode, 200, r.body); + const d = r.json(); + assert.equal(d.counts.cumulative, 3); + assert.equal(d.counts.daily, 3); + assert.equal(d.counts.period_visitors, 3); + assert.equal(d.counts.users, 3); + assert.ok(d.counts.weekly <= 3 && d.counts.monthly <= 3); + if (grain === "day") + assert.deepEqual( + d.series.map((r: any) => r.visitors), + [1, 3], + ); + } + const empty = await request( + admin, + "GET", + "/api/admin/dashboard?from=2026-01-01&to=2026-01-02", + ); + assert.equal(empty.json().series.length, 2); + assert.equal(empty.json().series[0].visitors, null); + assert.equal( + (await request(admin, "GET", "/api/admin/dashboard?grain=invalid")) + .statusCode, + 400, + ); + assert.equal( + ( + await db.query( + "SELECT count(*)::int AS n FROM business_events WHERE type='USER_REGISTERED' AND actor_id=$1", + [student.id], + ) + ).rows[0].n, + 1, + ); +}); +test("inquiry ownership, idempotency, reply visibility and conflicting updates", async () => { + const input = { + requestKey: "support-test-001", + category: "bug", + subject: "그래프 오류", + body: "PRIVATE_INQUIRY_CONTENT", + }; + const created = await request(student, "POST", "/api/support", input); + assert.equal(created.statusCode, 200, created.body); + const ticket = created.json(); + assert.equal( + (await request(student, "POST", "/api/support", input)).json().id, + ticket.id, + ); + assert.equal( + (await request(other, "GET", "/api/support")).json().rows.length, + 0, + ); + const adminList = await request( + admin, + "GET", + "/api/admin/inquiries?status=OPEN", + ); + assert.equal(adminList.json().rows[0].email, "student@example.test"); + assert.equal( + ( + await request(admin, "PATCH", "/api/admin/inquiries/" + ticket.id, { + revision: 0, + status: "RESOLVED", + reply: "", + }) + ).statusCode, + 400, + ); + assert.equal( + ( + await request(admin, "PATCH", "/api/admin/inquiries/" + ticket.id, { + revision: 0, + status: "RESOLVED", + reply: "문제를 해결했어요.", + }) + ).statusCode, + 200, + ); + assert.equal( + ( + await request(admin, "PATCH", "/api/admin/inquiries/" + ticket.id, { + revision: 0, + status: "OPEN", + reply: "stale", + }) + ).statusCode, + 409, + ); + assert.equal( + (await request(student, "GET", "/api/support")).json().rows[0].reply, + "문제를 해결했어요.", + ); + const events = (await request(admin, "GET", "/api/admin/events")).json(); + assert.ok(!JSON.stringify(events).includes("PRIVATE_INQUIRY_CONTENT")); + assert.equal( + events.rows.filter( + (r: any) => r.type === "INQUIRY_CREATED" && r.entity_id === ticket.id, + ).length, + 1, + ); +}); +test("cost import is atomic and repeatable; currencies and refunds stay separate", async () => { + const day = kstDate(); + const entries = [ + { + sourceKey: "gcp-day-krw", + usageDate: day, + service: "Compute Engine", + currency: "KRW", + amount: 1000, + source: "billing-export", + }, + { + sourceKey: "gemini-day-usd", + usageDate: day, + service: "Gemini", + currency: "USD", + amount: 1.5, + source: "verified-invoice", + }, + ]; + const imported = await request(admin, "POST", "/api/admin/costs/import", { + entries, + }); + assert.equal(imported.statusCode, 200, imported.body); + assert.equal( + (await request(admin, "POST", "/api/admin/costs/import", { entries })) + .statusCode, + 200, + ); + assert.equal( + ( + await request(admin, "POST", "/api/admin/costs/import", { + entries: [entries[0], entries[0]], + }) + ).statusCode, + 400, + ); + assert.equal( + ( + await request(admin, "POST", "/api/admin/costs/import", { + entries: [{ ...entries[0], usageDate: "2026-02-30" }], + }) + ).statusCode, + 400, + ); + assert.equal( + ( + await app.inject({ + method: "POST", + url: "/api/operations/costs/import", + payload: { entries }, + }) + ).statusCode, + 401, + ); + const operational = await app.inject({ + method: "POST", + url: "/api/operations/costs/import", + headers: { authorization: "Bearer operations" }, + payload: { entries: [{ ...entries[0], amount: 900 }] }, + }); + assert.equal(operational.statusCode, 200); + await db.query( + "INSERT INTO revenue_entries(id,provider_event_id,kind,currency,amount,occurred_at) VALUES($1,'payment-001','PAYMENT','KRW',3000,now()),($2,'refund-001','REFUND','KRW',1000,now())", + [id(), id()], + ); + const r = await request( + admin, + "GET", + `/api/admin/dashboard?from=${day}&to=${day}`, + ); + assert.equal(r.statusCode, 200, r.body); + const d = r.json(); + assert.equal(d.costs.length, 2); + assert.equal( + Number(d.costs.find((v: any) => v.currency === "KRW").amount), + 900, + ); + assert.equal( + Number(d.costs.find((v: any) => v.currency === "USD").amount), + 1.5, + ); + assert.equal(Number(d.revenue[0].net), 2000); + assert.equal(Number(d.revenue[0].refunds), 1000); + assert.equal(d.paymentEnabled, false); + const costs = await request(admin, "GET", "/api/admin/costs"); + assert.equal(costs.statusCode, 200, costs.body); + assert.equal(costs.json().rows.length, 2); +}); +test("business events remain after record deletion and retry keys do not inflate activity", async () => { + const r = ( + await request(student, "POST", "/api/records", { + problemId: "binary-search-v1", + language: "python", + }) + ).json(); + assert.ok(r.id); + const payload = { requestKey: "admin-log-execution", revision: 0 }; + const first = await request( + student, + "POST", + `/api/records/${r.id}/executions`, + payload, + ); + assert.equal(first.statusCode, 200, first.body); + await request(student, "POST", `/api/records/${r.id}/executions`, payload); + await request(student, "DELETE", "/api/records/" + r.id); + const rows = ( + await db.query("SELECT type FROM business_events WHERE entity_id=$1", [ + first.json().id, + ]) + ).rows; + assert.deepEqual( + rows.map((r) => r.type), + ["EXECUTION_REQUESTED"], + ); + assert.equal( + ( + await db.query( + "SELECT count(*)::int AS n FROM business_events WHERE type='RECORD_DELETED' AND entity_id=$1", + [r.id], + ) + ).rows[0].n, + 1, + ); + await assert.rejects( + db.tx(async (tx) => { + await businessEvent(tx, "ROLLBACK_EVENT", student.id, null, "rollback"); + throw new Error("rollback"); + }), + ); + assert.equal( + ( + await db.query( + "SELECT count(*)::int AS n FROM business_events WHERE event_key='rollback'", + ) + ).rows[0].n, + 0, + ); +}); diff --git a/apps/server/test/expansion.test.ts b/apps/server/test/expansion.test.ts index 1613f59..0aeae42 100644 --- a/apps/server/test/expansion.test.ts +++ b/apps/server/test/expansion.test.ts @@ -28,7 +28,7 @@ test("migration is repeatable and preserves saved data", async () => { assert.equal( (await db.query("SELECT max(version) AS n FROM schema_migrations")) .rows[0].n, - 4, + 5, ); } finally { await db.close(); diff --git a/apps/web/public/cost-import-example.json b/apps/web/public/cost-import-example.json new file mode 100644 index 0000000..17f8f92 --- /dev/null +++ b/apps/web/public/cost-import-example.json @@ -0,0 +1,12 @@ +{ + "entries": [ + { + "sourceKey": "REPLACE_WITH_UNIQUE_BILLING_ROW_KEY", + "usageDate": "2026-09-19", + "service": "REPLACE_WITH_SERVICE", + "currency": "KRW", + "amount": 0, + "source": "REPLACE_WITH_VERIFIED_BILLING_SOURCE" + } + ] +} diff --git a/apps/web/src/AdminDashboard.tsx b/apps/web/src/AdminDashboard.tsx new file mode 100644 index 0000000..ffce7c2 --- /dev/null +++ b/apps/web/src/AdminDashboard.tsx @@ -0,0 +1,721 @@ +import React, { useEffect, useState } from "react"; +import { api, post } from "./api.js"; +import { SupportPanel, Pager, displayTime } from "./SupportPanel.js"; +const today = () => + new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10); +const number = (n: number | string | null) => + n === null + ? "미수집" + : Number(n).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const money = (n: number | string, currency: string) => + `${number(n)} ${currency}`; +const eventLabels: Record = { + USER_REGISTERED: "회원 가입", + LOGIN: "로그인", + PRACTICE_STARTED: "코드 문제 시작", + QUIZ_ANSWERED: "퀴즈 제출", + EXECUTION_REQUESTED: "실행 요청", + EXECUTION_FINISHED: "실행 결과", + REVIEW_REQUESTED: "리뷰 요청", + REVIEW_SUCCEEDED: "리뷰 완료", + REVIEW_FAILED: "리뷰 실패", + INQUIRY_CREATED: "문의 접수", + INQUIRY_UPDATED: "문의 처리", + COSTS_IMPORTED: "비용 가져오기", + RECORD_DELETED: "학습 기록 삭제", +}; +export function AdminDashboard() { + const [tab, setTab] = useState("overview"), + [from, setFrom] = useState(today().slice(0, 7) + "-01"), + [to, setTo] = useState(today()), + [grain, setGrain] = useState("day"), + [query, setQuery] = useState(`from=${from}&to=${to}&grain=day`), + [data, setData] = useState(null), + [error, setError] = useState(""), + [refresh, setRefresh] = useState(0); + useEffect(() => { + let current = true; + setData(null); + setError(""); + api("/admin/dashboard?" + query) + .then((d) => { + if (current) setData(d); + }) + .catch((e) => { + if (current) setError(e.message); + }); + return () => { + current = false; + }; + }, [query, refresh]); + return ( +
+
+
+ ALGOSTEP / ADMIN +

운영 대시보드

+

방문부터 학습, 문의까지 서비스의 흐름을 확인하세요.

+
+ +
+ +
{ + e.preventDefault(); + setQuery(new URLSearchParams({ from, to, grain }).toString()); + }} + > + + + + + + KST · 최대 366일 +
+ 주간: 월요일 시작 · 월간: 매월 1일 시작 +
+
+ {error && ( +

+ {error} +

+ )} + {!data && !error &&

운영 데이터를 불러오는 중…

} + {data && ( + <> +

+ 조회 {data.range.from} ~ {data.range.to} · 갱신{" "} + {displayTime(data.generatedAt)} +

+ {tab === "overview" && ( + <> +
+ {[ + [ + "누적 접속자", + data.counts.cumulative, + "수집 시작 이후 고유 사용자", + ], + ["오늘 접속자", data.counts.daily, "KST 오늘"], + [ + "이번 주 접속자", + data.counts.weekly, + "이번 주 월요일부터 오늘", + ], + [ + "이번 달 접속자", + data.counts.monthly, + "이번 달 1일부터 오늘", + ], + ["전체 사용자", data.counts.users, "현재 등록된 계정"], + ["신규 사용자", data.counts.new_users, "선택 기간 가입 계정"], + [ + "기간 접속자", + data.counts.period_visitors, + "선택 기간 중복 제거", + ], + ["미처리 문의", data.counts.open_inquiries, "접수 + 처리 중"], + ].map(([title, value, note]) => ( +
+ {title} + {number(value)} + {note} +
+ ))} +
+

+ 접속자는 로그인 계정 기준이며 관리자도 포함됩니다. 여러 기기로 + 접속해도 한 명으로 집계합니다. 주·월 접속자는 일별 수의 합계가 + 아닙니다. 수집 시작:{" "} + {data.counts.tracking_since + ? displayTime(data.counts.tracking_since) + : "아직 접속 기록 없음"}{" "} + · 도입 이전 방문은 소급 추정하지 않습니다. 기간 경계의 주·월은 + 선택된 날짜만 집계합니다. +

+
+

접속자와 신규 사용자 추이

+ + [ + r.period, + number(r.visitors), + number(r.new_users), + ])} + /> +
+
+
+

비용

+ {data.costs.length ? ( + data.costs.map((r: any) => ( +

+ {money(r.amount, r.currency)} · 수집한 + 비용 {r.entries}건 +

+ )) + ) : ( +

비용 자료 미수집

+ )} +

+ 선택 기간에 가져온 실비만 표시합니다. 미수집은 0원과 + 다릅니다. +

+ +
+
+

매출액

+ {data.revenue.length ? ( + data.revenue.map((r: any) => ( +

순매출 {money(r.net, r.currency)}

+ )) + ) : ( +

0 KRW · 결제 미연동

+ )} +

+ 결제액에서 환불액을 뺀 금액입니다. 무료 리뷰는 매출에 + 포함하지 않습니다. +

+
+
+
+

주요 비즈니스 활동

+ [ + eventLabels[r.type] ?? r.type, + number(r.count), + ])} + /> +

+ 수집 시작 이후의 이벤트입니다. 방문 통계는 별도 중복 제거 + 집계입니다. +

+
+ + )} + {tab === "finance" && ( + setRefresh((v) => v + 1)} + /> + )} + {tab === "users" && ( + [ + r.display_name, + r.email, + displayTime(r.created_at), + r.last_visit ?? "수집 기록 없음", + ]} + /> + )} + {tab === "support" && ( + + )} + {tab === "events" && ( + + )} + + )} +
+ ); +} +function DataTable({ + headers, + rows, +}: { + headers: string[]; + rows: React.ReactNode[][]; +}) { + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.length ? ( + rows.map((row, i) => ( + + {row.map((v, j) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {h} +
{v}
+ 해당 기간에 수집된 데이터가 없어요. +
+
+ ); +} +function Trend({ series }: { series: any[] }) { + const max = Math.max(1, ...series.flatMap((r) => [r.visitors, r.new_users])); + const points = (key: string) => + series + .map((r, i) => ({ r, i })) + .filter(({ r }) => r[key] !== null) + .map( + ({ r, i }) => + `${40 + (i * 720) / Math.max(1, series.length - 1)},${190 - (r[key] / max) * 150}`, + ) + .join(" "); + return ( + <> +
+ ● 접속자 + ● 신규 사용자 +
+ + + {number(max)} + + + 0 + + + + + {series.map((r, i) => + r.visitors === null ? null : ( + + + {r.period}: 접속자 {r.visitors}, 신규 {r.new_users} + + + ), + )} + + {series[0]?.period} + + + {series.at(-1)?.period} + + + + ); +} +function PagedList({ + path, + title, + headers, + map, +}: { + path: string; + title: string; + headers: string[]; + map: (r: any) => React.ReactNode[]; +}) { + const [page, setPage] = useState(1), + [data, setData] = useState(null), + [error, setError] = useState(""); + useEffect(() => { + let active = true; + setData(null); + setError(""); + api(path + (path.includes("?") ? "&" : "?") + "page=" + page) + .then((d) => { + if (active) setData(d); + }) + .catch((e) => { + if (active) setError(e.message); + }); + return () => { + active = false; + }; + }, [path, page]); + return ( +
+

{title}

+ {error &&

{error}

} + {!data && !error &&

불러오는 중…

} + {data && ( + <> + + + + )} +
+ ); +} +function EventList({ query, events }: { query: string; events: any[] }) { + const [type, setType] = useState(""); + return ( + <> + + [ + displayTime(r.created_at), + eventLabels[r.type] ?? r.type, + r.actor_id ?? "시스템", + JSON.stringify(r.metadata), + ]} + /> +

+ 코드·문의 본문·인증 토큰은 비즈니스 로그에 저장하지 않습니다. +

+ + ); +} +function Finance({ + data, + query, + refresh, + onImport, +}: { + data: any; + query: string; + refresh: number; + onImport: () => void; +}) { + const [error, setError] = useState(""), + [notice, setNotice] = useState(""), + [busy, setBusy] = useState(false), + [entries, setEntries] = useState(null); + return ( + <> +
+
+

기간 비용 · 수집분

+ {data.costs.length ? ( + data.costs.map((r: any) => ( +

+ {money(r.amount, r.currency)} +
+ 최종 반영: {displayTime(r.updated_at)} +

+ )) + ) : ( +

비용 자료 미수집

+ )} +

+ GCP·Gemini·Supabase의 청구 자료를 가져오면 반영됩니다. 계정 + 크레딧·세금·지연 반영은 청구서와 대조해 주세요. 통화 간 합산은 하지 + 않습니다. +

+
+
+

매출액과 환불

+

현재 PG 결제 미연동 · 신규 유료 결제 비활성

+ {data.revenue.length ? ( + [ + r.currency, + number(r.gross), + number(r.refunds), + number(r.net), + ])} + /> + ) : ( + 결제 0 KRW · 환불 0 KRW · 순매출 0 KRW + )} +

+ 매출은 검증된 결제·환불 이벤트만 반영하도록 원장을 분리했습니다. PG + 연동 전에는 임의 매출을 입력할 수 없습니다. +

+
+
+
+

기간별 비용과 순매출

+ {["KRW", "USD"].map((currency) => { + const points = data.series.map((r: any) => ({ + period: r.period, + cost: data.costSeries.find( + (c: any) => c.period === r.period && c.currency === currency, + )?.amount, + revenue: data.revenueSeries.find( + (c: any) => c.period === r.period && c.currency === currency, + )?.amount, + })); + if ( + !points.some( + (p: any) => p.cost !== undefined || p.revenue !== undefined, + ) + ) + return null; + const max = Math.max( + 1, + ...points.map((p: any) => Math.abs(Number(p.cost ?? 0))), + ); + return ( +
+

{currency}

+
+ {points + .filter((p: any) => p.cost !== undefined) + .map((p: any) => ( +
+ {p.period} +
+ +
+ {money(p.cost, currency)} +
+ ))} +
+ [ + p.period, + p.cost === undefined ? "미수집" : money(p.cost, currency), + p.revenue === undefined + ? "결제 없음" + : money(p.revenue, currency), + ])} + /> +
+ ); + })} + {!data.costSeries.length && !data.revenueSeries.length && ( +

수집된 비용·매출이 생기면 차트와 표가 표시됩니다.

+ )} +
+
+

Gemini 등 LLM 실제 사용량

+ [ + r.provider + " / " + r.model, + number(r.calls), + number(r.measured_calls), + number(r.input_tokens), + number(r.output_tokens), + number(r.thinking_tokens), + ])} + /> +

+ 공급자가 반환한 사용량입니다. 토큰만으로 청구 금액을 확정하지 않으며 + 비용 원장과 중복 합산하지 않습니다. 응답을 받지 못한 호출 비용은 청구 + 자료에서 확인해야 합니다. +

+
+
+

비용 자료 가져오기

+

+ 검토한 JSON 파일을 선택한 뒤 적용하세요. 같은 sourceKey는 갱신되어 + 중복 합산되지 않습니다. 한 파일 최대 500행 · 512KB. +

+

+ 행 필드: sourceKey, usageDate (YYYY-MM-DD), service, currency + (KRW/USD), amount, source. amount는 크레딧 반영 후 비용이며 음수 + 조정도 허용합니다. +

+ + JSON 형식 예제 다운로드 + + + {entries && ( + <> +

+ 미리보기: {entries.length}행 · 적용 전 원본 키·금액을 확인해 + 주세요. +

+ [ + String(e.sourceKey ?? ""), + String(e.usageDate ?? ""), + String(e.service ?? ""), + String(e.currency ?? ""), + String(e.amount ?? ""), + ])} + /> + + + )} + {error &&

{error}

} + {notice &&

{notice}

} +
+ [ + r.usage_date, + r.service, + money(r.amount, r.currency), + r.source, + r.source_key, + ]} + /> + + ); +} diff --git a/apps/web/src/SupportPanel.tsx b/apps/web/src/SupportPanel.tsx new file mode 100644 index 0000000..249eacb --- /dev/null +++ b/apps/web/src/SupportPanel.tsx @@ -0,0 +1,277 @@ +import React, { useEffect, useRef, useState } from "react"; +import { api, post } from "./api.js"; +export const ticketLabels: Record = { + OPEN: "접수", + IN_PROGRESS: "처리 중", + RESOLVED: "완료", +}; +export const displayTime = (v: string) => + new Date(v).toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); +export function Pager({ + page, + more, + onChange, +}: { + page: number; + more: boolean; + onChange: (v: number) => void; +}) { + return ( +
+ + {page} 페이지 + +
+ ); +} +export function SupportPanel({ admin = false }: { admin?: boolean }) { + const [data, setData] = useState(null), + [page, setPage] = useState(1), + [status, setStatus] = useState(""), + [revision, setRevision] = useState(0), + [error, setError] = useState(""), + [busy, setBusy] = useState(false), + [notice, setNotice] = useState(""); + const [subject, setSubject] = useState(""), + [body, setBody] = useState(""), + [category, setCategory] = useState("question"); + const requestKey = useRef(crypto.randomUUID()); + const endpoint = admin ? "/admin/inquiries" : "/support"; + useEffect(() => { + let current = true; + setData(null); + setError(""); + api(`${endpoint}?page=${page}${status ? "&status=" + status : ""}`) + .then((d) => { + if (current) setData(d); + }) + .catch((e) => { + if (current) setError(e.message); + }); + return () => { + current = false; + }; + }, [endpoint, page, status, revision]); + return ( +
+
+
+ + {admin ? "CUSTOMER SUPPORT" : "HELP & FEEDBACK"} + +

{admin ? "문의 관리" : "문의하기"}

+

+ {admin + ? "사용자의 문의를 확인하고 답변과 처리 상태를 관리하세요." + : "문의와 답변은 이 화면에서 확인할 수 있어요. 비밀번호나 API 키는 적지 마세요."} +

+
+ +
+ {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {!admin && ( +
{ + e.preventDefault(); + setBusy(true); + setError(""); + setNotice(""); + try { + await post("/support", { + subject, + body, + category, + requestKey: requestKey.current, + }); + requestKey.current = crypto.randomUUID(); + setSubject(""); + setBody(""); + setPage(1); + setStatus(""); + setRevision((v) => v + 1); + setNotice( + "문의가 접수됐어요. 아래 목록에서 답변을 확인해 주세요.", + ); + } catch (e: any) { + setError(e.message); + } finally { + setBusy(false); + } + }} + > + + +