Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/daily-crawler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
# [PERF] 안전 타임아웃: 최대 90분 (월 3000분 한도 보호)
timeout-minutes: 90
if: ${{ github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch }}
if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && vars.G037_WRITE_FREEZE != 'active' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the write freeze to every scheduled writer

When G037_WRITE_FREEZE is set to active, this guard stops only the daily crawler and the GDrive backfill. I also checked .github/workflows/restaurant-refresh-cron.yml, whose scheduled non-dry-run path passes --allow-db-write with the Supabase service-role key, and .github/workflows/youtube-kpi-snapshot.yml, whose scheduled capture writes snapshots with the same key; neither job checks the freeze variable. Those production writes therefore continue during a declared repository-wide freeze, so add the admission guard to the remaining scheduled writer jobs as well.

Useful? React with 👍 / 👎.

env:
MAX_CONTEXT_VIDEOS: ${{ github.event.inputs.max_videos || '-1' }}
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/gdrive-frame-backfill.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
# Public standard runners do not consume the Pro 3000-minute quota, and
# catch-up backfills can legitimately exceed the former budget guard.
timeout-minutes: 360
if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.event == 'schedule')) }}
if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.event == 'schedule')) && vars.G037_WRITE_FREEZE != 'active' }}
env:
GDRIVE_FRAMES_PATH: "gdrive:04_빠른공유/tzudong_tzuyang_data/frames"
GDRIVE_STATUS_PATH: "gdrive:04_빠른공유/tzudong_tzuyang_data/status"
Expand Down
12 changes: 12 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,15 @@ THUMBNAIL_AGENT_ROOT=../../backend/thumbnail-agent
THUMBNAIL_AGENT_PYTHON=python3
THUMBNAIL_AGENT_RUNTIME=local_graph
THUMBNAIL_AGENT_TIMEOUT_MS=120000
# Device-location release gate (server-only; no NEXT_PUBLIC_* variants)
# 기본값: 차단. 누락/오류/형식 불일치이면 위치 맵 네트워크 준비 불가로 동작합니다.
DEVICE_LOCATION_RELEASE_DECISION=
# 외부 상태는 다음 값만 허용됨: not_applicable_verified | filing_receipt_verified
DEVICE_LOCATION_EXTERNAL_STATUS=
# 아래 4개 증거는 SHA-256 64자 hex 문자열이어야 합니다(대소문자 무관).
DEVICE_LOCATION_OPERATOR_EVIDENCE_HASH=
DEVICE_LOCATION_PROVIDER_EVIDENCE_HASH=
DEVICE_LOCATION_EXTERNAL_EVIDENCE_HASH=
DEVICE_LOCATION_LEGAL_EVIDENCE_HASH=
# ISO-8601 등 파싱 가능한 시간 문자열(예: 2026-07-12T00:00:00Z)
DEVICE_LOCATION_RELEASE_CONFIRMED_AT=
16 changes: 8 additions & 8 deletions apps/web/app/admin/banners/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapper
};

return (
<div className={cn("text-foreground", embedded ? "flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans tracking-normal" : "min-h-screen bg-[#fdfbf7] font-sans")}>
<div className={cn("text-foreground", embedded ? "flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans tracking-normal" : "min-h-screen bg-[#fdfbf7] font-sans")} data-admin-embedded-module-shell={embedded ? "true" : undefined} data-admin-embedded-module-id={embedded ? "banners" : undefined}>
{!embedded && (
<div
className="fixed inset-0 opacity-30 pointer-events-none z-0"
Expand All @@ -487,26 +487,26 @@ function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapper
)}

<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")}>
<div className={cn("flex flex-none flex-col gap-2 border-b border-border bg-card lg:flex-row lg:items-center lg:justify-between", embedded ? "px-2 py-1.5" : "rounded-t-2xl border px-3 py-2.5 shadow-sm")}>
<div className={cn("flex flex-none flex-col gap-2 border-b border-border bg-card lg:flex-row lg:items-center lg:justify-between", embedded ? "shrink-0 px-2 py-1.5" : "rounded-t-2xl border px-3 py-2.5 shadow-sm")} data-admin-module-header={embedded ? "compact" : undefined} data-admin-module-header-module={embedded ? "banners" : undefined}>
<div className="flex min-w-0 items-start gap-2">
{!embedded && (
<Button variant="ghost" size="icon" onClick={() => router.back()} className="h-9 w-9 rounded-xl hover:bg-muted" aria-label="이전 화면으로 돌아가기">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<div className="flex min-w-0 gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/20 bg-primary/5 text-primary">
<ImageIcon className="h-4 w-4" aria-hidden="true" />
<div className={cn("flex shrink-0 items-center justify-center text-primary", embedded ? "h-6 w-6" : "h-8 w-8 rounded-lg border border-primary/20 bg-primary/5")}>
<ImageIcon className={embedded ? "h-5 w-5" : "h-4 w-4"} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-[-0.04em] text-foreground md:text-xl">배너 관리</h1>
<p className="mt-0.5 text-xs leading-4 text-muted-foreground">
<h1 className={embedded ? "whitespace-nowrap bg-gradient-primary bg-clip-text text-base font-bold text-transparent" : "truncate text-lg font-bold tracking-[-0.04em] text-foreground md:text-xl"}>배너 관리</h1>
<p className="mt-0.5 text-xs leading-4 text-muted-foreground" data-admin-module-summary={embedded ? "true" : undefined}>
전체 {bannersLoading ? <InlineCountSkeleton /> : sortedBanners.length}개 · 활성 {bannersLoading ? <InlineCountSkeleton /> : activeBannerCount}개 · 비활성 {bannersLoading ? <InlineCountSkeleton /> : inactiveBannerCount}개
</p>
</div>
</div>
</div>
<div className="flex w-full min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center lg:w-auto">
<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" /> : 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" /> : mobileTargetCount}</Badge>
Expand All @@ -517,7 +517,7 @@ function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapper
</div>
</div>

<div className={cn("grid min-h-0 flex-1 gap-2 overflow-y-auto overflow-x-hidden scrollbar-hide [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", embedded ? "p-2 xl:grid-cols-[minmax(330px,0.95fr)_minmax(420px,1.05fr)] xl:overflow-hidden" : "rounded-b-2xl bg-background/70 p-2 sm:p-3 md:border md:border-t-0 xl:grid-cols-[minmax(360px,0.95fr)_minmax(460px,1.05fr)] xl:overflow-hidden")}>
<div className={cn("grid min-h-0 flex-1 gap-2 overflow-y-auto overflow-x-hidden scrollbar-hide [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", embedded ? "p-2 xl:grid-cols-[minmax(330px,0.95fr)_minmax(420px,1.05fr)] xl:overflow-hidden" : "rounded-b-2xl bg-background/70 p-2 sm:p-3 md:border md:border-t-0 xl:grid-cols-[minmax(360px,0.95fr)_minmax(460px,1.05fr)] xl:overflow-hidden")} data-admin-module-content={embedded ? "bounded" : undefined}>
<section className="min-h-0 overflow-hidden rounded-xl bg-card/95 shadow-sm md:border md:border-border xl:flex xl:flex-col" aria-labelledby="banner-list-title">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border p-2.5">
<div>
Expand Down
32 changes: 18 additions & 14 deletions apps/web/app/admin/evaluations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { debugLog } from '@/lib/debug-log';
import { getAdminEvaluationApprovalName, getAdminEvaluationDisplayName } from '@/lib/admin-evaluation-name';
import { getAddressConsistencyStatus } from '@/lib/admin-address-consistency';
import { needsEvaluationRerun } from '@/lib/admin-evaluation-completeness';
import { buildCanonicalAdminEvaluationsHref } from '@/lib/admin/admin-module-routing';
import { buildCanonicalAdminEvaluationsHref, type AdminConsoleRouteModuleId } from '@/lib/admin/admin-module-routing';
import {
isAdminEvaluationRecordMissing,
isAdminEvaluationRecordNotSelected,
Expand Down Expand Up @@ -976,7 +976,6 @@ function AdminEvaluationPage({

setIsSearching(true);
try {
// @ts-expect-error Supabase RPC inference is stale in the local generated client types.
const { data, error } = await supabase.rpc('search_restaurants_by_youtube_title', {
search_query: searchQuery.trim(),
max_results: 100,
Expand Down Expand Up @@ -1393,7 +1392,6 @@ function AdminEvaluationPage({

const { error: autoDeleteError } = await supabase
.from('restaurants')
// @ts-expect-error Supabase update inference is stale in the local generated client types.
.update({
status: 'deleted',
db_error_message: MISSING_EVALUATION_AUTO_DELETE_MESSAGE,
Expand Down Expand Up @@ -1637,7 +1635,6 @@ function AdminEvaluationPage({
assertLegacyBrowserAdminMutationEnabled('restaurant_record', 'record duplicate error update');
await supabase
.from('restaurants')
// @ts-expect-error Supabase update inference is stale in the local generated client types.
.update({
db_error_message: duplicateCheck.reason,
db_error_details: errorDetails,
Expand Down Expand Up @@ -1692,7 +1689,6 @@ function AdminEvaluationPage({
const updatedAt = new Date().toISOString();
const { data: updatedData, error } = await supabase
.from('restaurants')
// @ts-expect-error Supabase update inference is stale in the local generated client types.
.update({
status: 'approved',
approved_name: approvedName,
Expand Down Expand Up @@ -1757,7 +1753,6 @@ function AdminEvaluationPage({
// Soft Delete: 휴지통 아이콘 클릭 즉시 status를 'deleted'로 변경
const { error } = await supabase
.from('restaurants')
// @ts-expect-error Supabase update inference is stale in the local generated client types.
.update({
status: 'deleted',
updated_by_admin_id: adminUserId,
Expand Down Expand Up @@ -1830,7 +1825,6 @@ function AdminEvaluationPage({
// status를 'pending'으로 업데이트
const { error } = await supabase
.from('restaurants')
// @ts-expect-error Supabase update inference is stale in the local generated client types.
.update({
status: 'pending',
updated_by_admin_id: adminUserId,
Expand Down Expand Up @@ -1884,7 +1878,7 @@ function AdminEvaluationPage({
if (submissionsError) throw submissionsError;
if (!submissionsData?.length) return [];

const typedSubmissions = submissionsData as SubmissionRow[];
const typedSubmissions = submissionsData as unknown as SubmissionRow[];
const submissionIds = typedSubmissions.map(s => s.id);
const userIds = [...new Set(typedSubmissions.map(s => s.user_id))];

Expand All @@ -1902,7 +1896,7 @@ function AdminEvaluationPage({
]);

const typedProfilesData = (profilesData || []) as ProfileNicknameRow[];
const typedItemsData = (itemsData || []) as SubmissionItem[];
const typedItemsData = (itemsData || []) as unknown as SubmissionItem[];

const profilesMap = new Map(typedProfilesData.map((profile) => [profile.user_id, profile.nickname]));
const itemsMap = new Map<string, SubmissionItem[]>();
Expand Down Expand Up @@ -2115,7 +2109,7 @@ function AdminEvaluationPage({
if (reviewsError) throw reviewsError;
if (!reviewsData?.length) return [];

const typedReviewsData = reviewsData as Review[];
const typedReviewsData = reviewsData as unknown as Review[];
const userIds = [...new Set(typedReviewsData.map(r => r.user_id))];
const restaurantIds = [...new Set(typedReviewsData.map(r => r.restaurant_id))];

Expand Down Expand Up @@ -2853,14 +2847,24 @@ function AdminEvaluationPage({
? getRestaurantIdentityWarnings(pendingRecordAction.record)
: [];

const embeddedModuleId: Extract<AdminConsoleRouteModuleId, 'restaurants' | 'submissions' | 'reviews'> = showSubmissionView
? (submissionInitialTab === 'reviews' ? 'reviews' : 'submissions')
: 'restaurants';

return (
<div
ref={scrollContainerRef}
className="flex h-full min-h-0 flex-col overflow-auto"
id="scroll-container"
data-admin-embedded-module-shell={embedded ? "true" : undefined}
data-admin-embedded-module-id={embedded ? embeddedModuleId : undefined}
>
{/* Header */}
<div className={embedded ? "border-b border-border bg-card px-2 py-1.5" : "border-b border-border bg-card px-3 py-2.5 sm:px-4 sm:py-3"}>
<div
className={embedded ? "shrink-0 border-b border-border bg-card px-2 py-1.5" : "border-b border-border bg-card px-3 py-2.5 sm:px-4 sm:py-3"}
data-admin-module-header={embedded ? "compact" : undefined}
data-admin-module-header-module={embedded ? embeddedModuleId : undefined}
>
<div className={embedded ? "flex flex-row items-start justify-between gap-1.5 lg:items-center" : "flex flex-row items-start justify-between gap-2.5 lg:items-center"}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -2897,7 +2901,7 @@ function AdminEvaluationPage({
</Button>
</div>
)}
<div className={embedded ? "mt-0.5 truncate text-xs text-muted-foreground" : "mt-0.5 truncate text-xs text-muted-foreground sm:text-sm"}>
<div className={embedded ? "mt-0.5 truncate text-xs text-muted-foreground" : "mt-0.5 truncate text-xs text-muted-foreground sm:text-sm"} data-admin-module-summary={embedded ? "true" : undefined}>
{pendingQueueSummaryContent}
</div>
</div>
Expand All @@ -2909,7 +2913,7 @@ function AdminEvaluationPage({
selectedStatuses={selectedStatuses}
onSelectStatuses={setSelectedStatuses}
>
<div className="ml-auto flex items-center justify-end gap-1.5 lg:gap-1" data-admin-evaluation-view-actions="top-right">
<div className="ml-auto flex items-center justify-end gap-1.5 lg:gap-1" data-admin-evaluation-view-actions="top-right" data-admin-module-actions={embedded ? "top-right" : undefined}>
{canSwitchEvaluationView && (
<>
<Button
Expand Down Expand Up @@ -2982,7 +2986,7 @@ function AdminEvaluationPage({
</div>
</div>

<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col" data-admin-module-content={embedded ? "bounded" : undefined}>
{pendingRecordAction && (
<section
role="region"
Expand Down
Loading