Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6bc5391
Unify admin headers and protect Gemini verification output
twoimo Oct 12, 2026
5f17b5f
Fix admin loading contracts and Gemini source dirtiness receipts
twoimo Oct 12, 2026
b6ae27d
Unify standalone admin header and verify responsive route census
twoimo Oct 12, 2026
cf9bc10
Reconcile retained UI evidence and full-goal execution todos
twoimo Oct 13, 2026
a45a6bb
Record verified terminal boundaries and remaining operating work
twoimo Oct 13, 2026
6cbff50
Preserve unknown incident counts and durable Gemini reservations
twoimo Oct 13, 2026
bfea9d0
Merge remote-tracking branch 'origin/develop' into codex/goal-deliver…
twoimo Oct 13, 2026
bb6f922
Make privacy fixture redaction checks portable across home paths
twoimo Oct 13, 2026
918ef6b
Align admin verifiers and isolate Gemini generation uncertainty
twoimo Oct 13, 2026
c503483
Merge remote-tracking branch 'origin/develop' into codex/goal-deliver…
twoimo Oct 13, 2026
a077a1f
Harden admin fixture startup and immutable DB replay evidence
twoimo Oct 13, 2026
97fad87
Keep historical verification inputs outside active CI discovery
twoimo Oct 13, 2026
3777eca
Merge remote-tracking branch 'origin/develop' into codex/goal-deliver…
twoimo Oct 13, 2026
b85a10f
Harden privacy loopback launch integrity
twoimo Oct 14, 2026
a6ee029
Bind fixture dependency closure and historical OCR configuration
twoimo Oct 14, 2026
7c2ad0c
Merge remote-tracking branch 'origin/develop' into codex/goal-deliver…
twoimo Oct 14, 2026
1f97ab6
Release owned controller test fixtures after each case
twoimo Oct 14, 2026
66ad237
Bind admin runtime readback and fixture evidence to verified artifacts
twoimo Oct 15, 2026
2d7e284
Merge remote-tracking branch 'origin/develop' into codex/goal-deliver…
twoimo Oct 15, 2026
4c87bf7
Record verification of the admin candidate against current develop
twoimo Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 7 additions & 9 deletions apps/web/app/admin/banners/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { AdminPageHeader } from "@/components/admin/AdminPageHeader";
import { getAdminModuleTitle } from "@/lib/admin/admin-module-metadata";

import { useState, useRef, useMemo, Suspense, useEffect, useLayoutEffect } from 'react';
import { useFilledSkeletonCount } from '@/lib/use-filled-skeleton-count';
Expand Down Expand Up @@ -768,15 +769,12 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem
)}

<div className={cn("relative z-10 flex min-h-0 flex-1 flex-col", embedded ? "h-full" : "container mx-auto min-h-screen max-w-7xl p-3 md:p-4")}>
<AdminPageHeader title="배너 관리" icon={ImageIcon}
<AdminPageHeader title={getAdminModuleTitle("banners")} icon={ImageIcon}
data-admin-module-header={embedded ? "compact" : undefined} data-admin-module-header-module={embedded ? "banners" : undefined}
summary={<>전체 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(sortedBanners.length, '개')} · 활성 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(activeBannerCount, '개')} · 비활성 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(inactiveBannerCount, '개')}</>}
actions={<div className="flex w-full min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center lg:w-auto" data-admin-module-actions={embedded ? "top-right" : undefined}>
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-x-auto pb-1 scrollbar-hide [scrollbar-width:none] sm:flex-wrap sm:overflow-visible sm:pb-0 [&::-webkit-scrollbar]:hidden">
<Badge variant="secondary" className="shrink-0 whitespace-nowrap rounded-full border border-border bg-muted/50 text-muted-foreground"><Monitor className="mr-1 h-3.5 w-3.5" aria-hidden="true" />데스크톱 배너 {bannersLoading ? <InlineCountSkeleton className="ml-1 w-5" /> : formatBannerCount(sidebarTargetCount)}</Badge>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap rounded-full border border-border bg-muted/50 text-muted-foreground"><Smartphone className="mr-1 h-3.5 w-3.5" aria-hidden="true" />모바일 팝업 {bannersLoading ? <InlineCountSkeleton className="ml-1 w-5" /> : formatBannerCount(mobileTargetCount)}</Badge>
</div>
<Button onClick={openCreatePanel} disabled={isBusy || Boolean(pendingReadback)} className="h-9 w-full rounded-md bg-primary px-3 text-primary-foreground hover:bg-primary/90 sm:w-auto">
summary={<>전체 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(sortedBanners.length, '개')} · 활성 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(activeBannerCount, '개')} · 비활성 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(inactiveBannerCount, '개')} · 데스크톱 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(sidebarTargetCount)} · 모바일 {bannersLoading ? <InlineCountSkeleton /> : formatBannerCount(mobileTargetCount)}</>}
actions={<div className="flex min-w-0 items-center gap-1.5" data-admin-module-actions={embedded ? "top-right" : undefined}>
<Button type="button" variant="ghost" size="sm" aria-label="배너 목록 새로고침" disabled={bannersFetching || isBusy} onClick={() => void refreshBannerList()}><RefreshCw className={cn("h-4 w-4", bannersFetching && "animate-spin")} aria-hidden="true" />새로고침</Button>
<Button onClick={openCreatePanel} disabled={isBusy || Boolean(pendingReadback)} className="h-9 w-auto shrink-0 rounded-md bg-primary px-3 text-primary-foreground hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />새 배너
</Button>
</div>}
Expand All @@ -793,7 +791,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem
<div className="flex w-full min-w-0 gap-2"><div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<Input id="admin-banner-search" aria-label="배너 제목·설명 검색" placeholder="제목·설명 검색" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} className="h-9 pl-9" />
</div><Button type="button" variant="outline" size="icon" className="h-9 w-9 shrink-0" aria-label="배너 목록 새로고침" disabled={bannersFetching || isBusy} onClick={() => void refreshBannerList()}><RefreshCw className={cn("h-4 w-4", bannersFetching && "animate-spin")} aria-hidden="true" /></Button></div>
</div></div>
<div className="flex w-full flex-wrap items-center gap-2">
<select aria-label="배너 활성 상태 필터" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)} className="h-8 rounded-md border border-input bg-background px-2 text-xs"><option value="all">모든 상태</option><option value="active">활성</option><option value="inactive">비활성</option></select>
<select aria-label="배너 표시 위치 필터" value={targetFilter} onChange={(event) => setTargetFilter(event.target.value)} className="h-8 rounded-md border border-input bg-background px-2 text-xs"><option value="all">모든 위치</option><option value="sidebar">데스크톱 배너</option><option value="mobile_popup">모바일 팝업</option></select>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/app/admin/evaluations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { AdminPageHeader } from "@/components/admin/AdminPageHeader";
import { getAdminModuleTitle } from "@/lib/admin/admin-module-metadata";

import { createPortal } from 'react-dom';
import { useRestaurantManagementHeader } from '@/components/admin/RestaurantManagementWorkspace';
Expand Down Expand Up @@ -2142,7 +2143,7 @@ function AdminEvaluationPage({
const embeddedModuleId: Extract<AdminConsoleRouteModuleId, 'restaurants' | 'submissions' | 'reviews'> = showSubmissionView
? (submissionInitialTab === 'reviews' ? 'reviews' : 'submissions')
: 'restaurants';
const ModuleTitle = embedded ? 'h2' : 'h1';
const ModuleTitle = 'h1';
const compactReviewHeader = embedded && embeddedModuleId === 'restaurants' && managementHeader !== null;

const reviewSummary = recordViewsInvalidated ? '새 조회 필요' : !legacyEvaluationLoad && pageReadError ? '조회 실패' : !isInitialEvaluationDataLoading
Expand Down Expand Up @@ -2199,8 +2200,8 @@ function AdminEvaluationPage({
managementHeader.views,
)}
{embedded && !compactReviewHeader && <AdminPageHeader
title={embeddedModuleId === 'submissions' ? '제보 관리' : embeddedModuleId === 'reviews' ? '리뷰 관리' : '관리자 데이터 검수'}
titleAs="h2" icon={ClipboardCheck}
title={getAdminModuleTitle(embeddedModuleId)}
icon={ClipboardCheck}
summary={embeddedModuleId === 'restaurants' ? reviewSummary : pendingQueueSummaryContent}
data-admin-module-header="compact" data-admin-module-header-module={embeddedModuleId}
actions={canSwitchEvaluationView ? <div data-admin-evaluation-view-actions="top-right">{reviewViewActions}</div> : undefined}
Expand Down
94 changes: 71 additions & 23 deletions apps/web/app/admin/privacy-incidents/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, ShieldAlert } from 'lucide-react';

import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
import {
getIncidentListPresentation,
nextIncidentListReadState,
type IncidentListReadState,
} from '@/lib/privacy/incident-list-read-state';

type IncidentStatus =
| 'detected'
Expand Down Expand Up @@ -522,6 +530,7 @@ export default function PrivacyIncidentsPage() {
const [receipt, setReceipt] = useState<ReadbackReceipt | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [incidentListReadState, setIncidentListReadState] = useState<IncidentListReadState>('pending');
const [detectionSeverity, setDetectionSeverity] = useState<IncidentSeverity>('medium');
const [detectedAt, setDetectedAt] = useState(localDateTimeValue);
const [detectionConfirmationText, setDetectionConfirmationText] = useState('');
Expand All @@ -530,6 +539,7 @@ export default function PrivacyIncidentsPage() {

const load = useCallback(async () => {
setLoading(true);
setIncidentListReadState((current) => nextIncidentListReadState(current, 'start'));
try {
const response = await fetch('/api/admin/privacy-incidents', {
headers: { Accept: 'application/json' },
Expand All @@ -546,10 +556,13 @@ export default function PrivacyIncidentsPage() {
setIncidents(sortedIncidents);
setNotices(nextNotices);
setActions(nextActions);
setIncidentListReadState((current) => nextIncidentListReadState(current, 'success'));
setMessage(null);
setSelectedIncidentId((current) => current && sortedIncidents.some((incident) => incident.id === current)
? current
: sortedIncidents[0]?.id ?? null);
} catch (error) {
setIncidentListReadState((current) => nextIncidentListReadState(current, 'failure'));
setMessage(errorMessage(error));
} finally {
setLoading(false);
Expand All @@ -573,6 +586,15 @@ export default function PrivacyIncidentsPage() {
() => actions.filter((action) => action.incidentId === selectedIncident?.id),
[actions, selectedIncident?.id],
);
const deadlineRiskCount = useMemo(
() => incidents.filter((incident) => incident.deadlineStatus === 'overdue' || incident.deadlineStatus === 'due_soon').length,
[incidents],
);
const incidentListPresentation = getIncidentListPresentation(
incidentListReadState,
incidents.length,
deadlineRiskCount,
);

const transitionInput = useMemo((): Record<string, unknown> | null => {
if (!nextStatus) return null;
Expand Down Expand Up @@ -800,19 +822,36 @@ export default function PrivacyIncidentsPage() {
};

return (
<main className="mx-auto max-w-6xl space-y-6 p-4 md:p-8" data-privacy-incident-workflow="true">
<header className="space-y-2">
<p className="text-sm font-medium text-amber-700">개인정보 사고 담당 관리자 전용</p>
<h1 className="text-2xl font-bold">개인정보 사고 대응</h1>
<p className="max-w-4xl text-sm text-muted-foreground">
이 화면은 사실관계와 운영자 결정을 기록하는 도구입니다. 신고 대상, 법적 적용, 외부 수리·승인은 사람이 결정합니다.
외부 제출은 자동으로 수행되지 않습니다.
<main className="min-h-screen bg-muted/20" data-privacy-incident-workflow="true">
<AdminPageHeader
title="개인정보 사고 대응"
titleId="admin-privacy-incidents-title"
icon={ShieldAlert}
summary={incidentListPresentation.summary}
actions={(
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-muted disabled:opacity-50"
onClick={() => void load()}
disabled={loading}
aria-label="개인정보 사고 목록 새로고침"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
새로고침
</button>
)}
data-admin-module-header="compact"
data-admin-module-header-module="privacy-incidents"
/>

<div className="mx-auto max-w-7xl space-y-3 p-3 md:p-5">
<p className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-950" data-privacy-incident-operator-boundary="true">
사실관계와 운영자 결정을 기록합니다. 신고 대상·법적 적용·외부 수리·승인은 사람이 결정하며 외부 제출은 자동으로 수행되지 않습니다.
</p>
</header>

{message ? <p role="alert" className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm">{message}</p> : null}
{message ? <p role="alert" className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm">{message}</p> : null}

<section className="space-y-4 rounded-lg border p-4" aria-label="사고 탐지 등록" data-privacy-incident-detection-intake="true">
<section className="space-y-4 rounded-lg border bg-card p-4" aria-label="사고 탐지 등록" data-privacy-incident-detection-intake="true">
<div>
<h2 className="font-semibold">사고 탐지 등록</h2>
<p className="mt-1 text-sm text-muted-foreground">
Expand Down Expand Up @@ -848,16 +887,24 @@ export default function PrivacyIncidentsPage() {
<p>읽기검증: {detectionReceipt.readback.passed ? '통과' : '실패'}</p>
</div>
) : null}
</section>
</section>

<section className="rounded-lg border p-4" aria-label="사고 목록">
<div className="mb-3 flex items-center justify-between gap-3">
<section className="rounded-lg border bg-card p-4" aria-label="사고 목록">
<div className="mb-3 flex items-center gap-3">
<h2 className="font-semibold">사고 목록</h2>
<button type="button" className="rounded border px-3 py-1 text-sm" onClick={() => void load()} disabled={loading}>새로고침</button>
</div>
{incidents.length === 0 ? (
<p className="text-sm text-muted-foreground">표시할 사고가 없습니다. 위 탐지 등록으로 첫 사고를 기록한 뒤 상태 전환을 진행하세요.</p>
) : (
{incidentListPresentation.statusMessage ? (
<p
role={incidentListReadState === 'failed' || incidentListReadState === 'stale' ? 'alert' : 'status'}
className="mb-3 text-sm text-muted-foreground"
data-privacy-incident-list-read-state={incidentListReadState}
>
{incidentListPresentation.statusMessage}
</p>
) : null}
{incidentListPresentation.hasKnownData && incidentListPresentation.emptyMessage ? (
<p className="text-sm text-muted-foreground">{incidentListPresentation.emptyMessage}</p>
) : incidentListPresentation.hasKnownData ? (
<div className="grid gap-2 md:grid-cols-2">
{incidents.map((incident) => (
<button
Expand All @@ -875,12 +922,12 @@ export default function PrivacyIncidentsPage() {
</button>
))}
</div>
)}
</section>
) : null}
</section>

{selectedIncident ? (
{selectedIncident ? (
<section className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(18rem,0.7fr)]">
<div className="space-y-5 rounded-lg border p-4">
<div className="space-y-5 rounded-lg border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="font-semibold">현재 상태: {STATUS_LABEL[selectedIncident.status]}</h2>
Expand Down Expand Up @@ -991,7 +1038,7 @@ export default function PrivacyIncidentsPage() {
) : null}
</div>

<aside className="space-y-4 rounded-lg border p-4">
<aside className="space-y-4 rounded-lg border bg-card p-4">
<div>
<h2 className="font-semibold">통지 상태</h2>
<div className="mt-2 space-y-2">
Expand Down Expand Up @@ -1019,7 +1066,8 @@ export default function PrivacyIncidentsPage() {
</div>
</aside>
</section>
) : null}
) : null}
</div>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'server-only';

import { NextResponse } from 'next/server';

import { requireAdmin } from '@/lib/auth/require-admin';

const TZUDONG_VERCEL_PROJECT_ID = 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl';
const DEPLOYMENT_ID = /^dpl_[A-Za-z0-9]+$/;
const GIT_SHA = /^[0-9a-f]{40}$/;
const PRIVATE_HEADERS = Object.freeze({
'Cache-Control': 'private, no-store, max-age=0',
Pragma: 'no-cache',
});

type AdminResult = Awaited<ReturnType<typeof requireAdmin>>;
type RequireAdmin = () => Promise<AdminResult>;

function fixedResponse(code: string, status: number) {
return NextResponse.json({ code }, { headers: PRIVATE_HEADERS, status });
}

function fixedAuthFailure(response: NextResponse) {
const status = response.status === 401 ? 401 : 403;
return fixedResponse(
status === 401 ? 'OPERATING_READBACK_UNAUTHORIZED' : 'OPERATING_READBACK_FORBIDDEN',
status,
);
}

function readRuntimeIdentity(environment: NodeJS.ProcessEnv) {
const deploymentId = environment.VERCEL_DEPLOYMENT_ID?.trim() ?? '';
const commit = environment.VERCEL_GIT_COMMIT_SHA?.trim() ?? '';
if (environment.VERCEL !== '1'
|| environment.VERCEL_ENV !== 'production'
|| environment.VERCEL_TARGET_ENV !== 'production'
|| environment.VERCEL_PROJECT_ID !== TZUDONG_VERCEL_PROJECT_ID
|| environment.VERCEL_GIT_COMMIT_REF !== 'main'
|| !DEPLOYMENT_ID.test(deploymentId)
|| !GIT_SHA.test(commit)) {
return null;
}
return Object.freeze({
commit,
deploymentId,
holdActive: environment.ADMIN_RECORD_MUTATIONS_HOLD !== 'cleared',
});
}

function responseAfterAuthentication(auth: AdminResult, environment: NodeJS.ProcessEnv) {
if (!auth.ok) return fixedAuthFailure(auth.response);

const readback = readRuntimeIdentity(environment);
if (!readback) return fixedResponse('OPERATING_READBACK_RUNTIME_UNAVAILABLE', 503);
return NextResponse.json(readback, { headers: PRIVATE_HEADERS, status: 200 });
}

export async function createRecordMutationsHoldReadbackResponse({
environment = process.env,
requireAdminImpl = requireAdmin,
}: {
environment?: NodeJS.ProcessEnv;
requireAdminImpl?: RequireAdmin;
} = {}) {
let auth: AdminResult;
try {
auth = await requireAdminImpl();
} catch {
return fixedResponse('OPERATING_READBACK_AUTH_UNAVAILABLE', 503);
}
return responseAfterAuthentication(auth, environment);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createRecordMutationsHoldReadbackResponse } from './readback-response';

export const runtime = 'nodejs';

export async function GET() {
return createRecordMutationsHoldReadbackResponse();
}
1 change: 0 additions & 1 deletion apps/web/app/insights/insights-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,6 @@ export default function InsightsClient({ embedded = false }: { embedded?: boolea
<AdminEmbeddedModuleShell
moduleId="insights"
titleId="admin-insights-title"
title="영상 성과 분석"
icon={Lightbulb}
summary={summary}
contentClassName="p-2"
Expand Down
2 changes: 2 additions & 0 deletions apps/web/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
pathIgnorePatterns = ["performance/**/historical-*/**"]
Loading
Loading