diff --git a/components/TransactionListRow.vue b/components/TransactionListRow.vue index b7b1ece..e7afe75 100644 --- a/components/TransactionListRow.vue +++ b/components/TransactionListRow.vue @@ -1,6 +1,7 @@ diff --git a/pages/share/[token].vue b/pages/share/[token].vue new file mode 100644 index 0000000..79db624 --- /dev/null +++ b/pages/share/[token].vue @@ -0,0 +1,106 @@ + + + diff --git a/prisma/migrations/20260813090000_add_share_links/migration.sql b/prisma/migrations/20260813090000_add_share_links/migration.sql new file mode 100644 index 0000000..9f886ee --- /dev/null +++ b/prisma/migrations/20260813090000_add_share_links/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "ShareLink" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "lastViewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ShareLink_token_key" ON "ShareLink"("token"); + +-- CreateIndex +CREATE INDEX "ShareLink_userId_createdAt_idx" ON "ShareLink"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ShareLink_token_expiresAt_revokedAt_idx" ON "ShareLink"("token", "expiresAt", "revokedAt"); + +-- AddForeignKey +ALTER TABLE "ShareLink" ADD CONSTRAINT "ShareLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05d0991..c7ff3ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model User { categories Category[] transactions Transaction[] recurringTransactions RecurringTransaction[] + shareLinks ShareLink[] } model Asset { @@ -137,3 +138,20 @@ model RecurringTransaction { @@index([userId, active, nextRunAt]) } + +model ShareLink { + id String @id @default(cuid()) + userId String + token String @unique + name String + expiresAt DateTime + revokedAt DateTime? + lastViewedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([token, expiresAt, revokedAt]) +} diff --git a/server/api/share-links/[id].delete.ts b/server/api/share-links/[id].delete.ts new file mode 100644 index 0000000..60b18d1 --- /dev/null +++ b/server/api/share-links/[id].delete.ts @@ -0,0 +1,20 @@ +import { getRouterParam, createError } from 'h3' +import { prisma } from '../../utils/db' +import { requireUser } from '../../utils/auth' + +export default defineEventHandler(async (event) => { + const user = await requireUser(event) + const id = getRouterParam(event, 'id')! + const link = await prisma.shareLink.findFirst({ where: { id, userId: user.id } }) + + if (!link) { + throw createError({ statusCode: 404, statusMessage: 'Share link not found' }) + } + + await prisma.shareLink.update({ + where: { id }, + data: { revokedAt: new Date() } + }) + + return { ok: true } +}) diff --git a/server/api/share-links/index.get.ts b/server/api/share-links/index.get.ts new file mode 100644 index 0000000..b0d87f2 --- /dev/null +++ b/server/api/share-links/index.get.ts @@ -0,0 +1,21 @@ +import { prisma } from '../../utils/db' +import { requireUser } from '../../utils/auth' + +export default defineEventHandler(async (event) => { + const user = await requireUser(event) + const links = await prisma.shareLink.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' } + }) + + return links.map(link => ({ + id: link.id, + name: link.name, + token: link.token, + expiresAt: link.expiresAt, + revokedAt: link.revokedAt, + lastViewedAt: link.lastViewedAt, + createdAt: link.createdAt, + active: !link.revokedAt && link.expiresAt > new Date() + })) +}) diff --git a/server/api/share-links/index.post.ts b/server/api/share-links/index.post.ts new file mode 100644 index 0000000..48fdbbd --- /dev/null +++ b/server/api/share-links/index.post.ts @@ -0,0 +1,35 @@ +import { randomBytes } from 'node:crypto' +import { addDays } from 'date-fns' +import { readBody, createError } from 'h3' +import { prisma } from '../../utils/db' +import { requireUser } from '../../utils/auth' + +export default defineEventHandler(async (event) => { + const user = await requireUser(event) + const body = await readBody<{ name?: string; days?: number }>(event) + const days = Number(body.days || 7) + + if (!Number.isFinite(days) || days < 1 || days > 365) { + throw createError({ statusCode: 400, statusMessage: 'Invalid expiration days' }) + } + + const link = await prisma.shareLink.create({ + data: { + userId: user.id, + token: randomBytes(24).toString('base64url'), + name: body.name?.trim().slice(0, 40) || '공유 링크', + expiresAt: addDays(new Date(), days) + } + }) + + return { + id: link.id, + name: link.name, + token: link.token, + expiresAt: link.expiresAt, + revokedAt: link.revokedAt, + lastViewedAt: link.lastViewedAt, + createdAt: link.createdAt, + active: true + } +}) diff --git a/server/api/shared/[token].get.ts b/server/api/shared/[token].get.ts new file mode 100644 index 0000000..3ff17fb --- /dev/null +++ b/server/api/shared/[token].get.ts @@ -0,0 +1,58 @@ +import { getQuery, getRouterParam, createError } from 'h3' +import { startOfMonth, endOfMonth } from 'date-fns' +import { prisma } from '../../utils/db' + +export default defineEventHandler(async (event) => { + const token = getRouterParam(event, 'token')! + const query = getQuery(event) + const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7) + const monthStart = startOfMonth(new Date(`${month}-01T00:00:00`)) + const monthEnd = endOfMonth(monthStart) + + const link = await prisma.shareLink.findUnique({ + where: { token }, + include: { user: { select: { id: true, name: true } } } + }) + + if (!link || link.revokedAt || link.expiresAt <= new Date()) { + throw createError({ statusCode: 404, statusMessage: 'Share link not found' }) + } + + const [transactions, assets] = await Promise.all([ + prisma.transaction.findMany({ + where: { + userId: link.userId, + occurredAt: { gte: monthStart, lte: monthEnd } + }, + include: { + asset: true, + category: { include: { parent: true } } + }, + orderBy: { occurredAt: 'desc' } + }), + prisma.asset.findMany({ + where: { userId: link.userId, active: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] + }) + ]) + + await prisma.shareLink.update({ + where: { id: link.id }, + data: { lastViewedAt: new Date() } + }) + + const summary = transactions.reduce((acc, tx) => { + acc[tx.type] += tx.amount + acc.diff = acc.income - acc.expense + return acc + }, { income: 0, expense: 0, diff: 0 }) + + return { + owner: link.user.name, + name: link.name, + expiresAt: link.expiresAt, + summary, + assets, + transactions + } +}) diff --git a/todo.md b/todo.md index 9521f80..a0a459f 100644 --- a/todo.md +++ b/todo.md @@ -63,7 +63,6 @@ - 검색과 필터용 API query를 추가한다. - 카테고리 아이콘/색상 필드를 Prisma schema에 추가한다. - 사용자 설정 테이블을 추가한다. -- 읽기 전용 공유 링크 테이블과 만료 기간 필드를 추가한다. - 거래 목록 조회 성능을 고려해 필요한 index를 점검한다. - CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다. @@ -73,18 +72,12 @@ - 비밀번호 변경 기능을 구현한다. - 회원 탈퇴 또는 데이터 삭제 정책을 정한다. - 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다. -- 로그아웃 동작과 위치를 정리한다. -- 첫 실행 시 기본 자산/카테고리 생성 흐름을 점검한다. - 빈 데이터 상태 화면을 페이지별로 다듬는다. ## 10. 공유 -- 사용자가 공유 링크를 생성하는 화면을 구현한다. -- 공유 링크의 조회 가능 기간을 설정할 수 있게 한다. -- 공유 링크 접근자는 읽기 전용으로만 내역, 통계, 자산을 볼 수 있게 한다. -- 공유 링크 만료, 삭제, 재생성 기능을 구현한다. -- 공유 화면에서 편집, 삭제, 내보내기 같은 쓰기 기능을 숨긴다. -- 공유 링크 접근 로그 또는 마지막 조회 시각을 기록할지 결정한다. +- 공유 링크 재생성 기능을 구현한다. +- 공유 접근 화면의 자산/통계 표시를 더 풍부하게 만든다. ## 11. 품질