docs: plan admin gallery prompt notifications
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
# Admin Gallery Prompt Notification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build an admin console workflow that edits public gallery work prompts only after successfully emailing the work author.
|
||||
|
||||
**Architecture:** Put the moderation rule in a small service module with injected database/email/log dependencies, expose thin admin API routes, and add a dedicated admin console tab. The public gallery page remains unchanged except for shared data compatibility through the `works.prompt` field.
|
||||
|
||||
**Tech Stack:** Next.js route handlers, React client components, PostgreSQL via existing `getDbClient`, existing `sendTemplatedEmail`, existing `writePlatformLog`, TypeScript, and a lightweight Node test script.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/lib/admin-gallery-prompt-service.ts`: validates prompt edit requests, loads a public work and author, sends email first, updates `works.prompt`, writes moderation logs.
|
||||
- Create `src/app/api/admin/gallery/works/route.ts`: admin-only list API for public gallery works.
|
||||
- Create `src/app/api/admin/gallery/prompt/route.ts`: admin-only prompt update API that delegates to the service.
|
||||
- Create `src/components/admin/gallery-management-tab.tsx`: admin UI for listing public works and editing prompts with required email notification.
|
||||
- Modify `src/modules/console/pages/console-dashboard-page.tsx`: register the new `gallery` view in navigation and content routing.
|
||||
- Create `scripts/test-admin-gallery-prompt-service.mjs`: service-level TDD tests using an in-memory fake database adapter.
|
||||
- Modify `package.json`: add `test:admin-gallery-prompt`.
|
||||
- Update `docs/codex-miaojing/api-reference.md`: document new admin gallery APIs.
|
||||
- Update `docs/codex-miaojing/feature-code-index.md`: index the new feature files.
|
||||
- Update `docs/codex-miaojing/architecture.md` only if moderation workflow context is missing.
|
||||
- Update `docs/codex-miaojing/bug-location-guide.md` only if implementation reveals useful troubleshooting notes.
|
||||
|
||||
## Task 1: Service Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/test-admin-gallery-prompt-service.mjs`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Add a test script entry**
|
||||
|
||||
Add this script to `package.json`:
|
||||
|
||||
```json
|
||||
"test:admin-gallery-prompt": "tsx ./scripts/test-admin-gallery-prompt-service.mjs"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing service tests**
|
||||
|
||||
Create `scripts/test-admin-gallery-prompt-service.mjs` that imports `updateAdminGalleryPrompt` from `src/lib/admin-gallery-prompt-service.ts` and tests:
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict';
|
||||
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
|
||||
|
||||
function createServiceHarness({ work, emailFails = false } = {}) {
|
||||
const state = {
|
||||
work: work || {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
type: 'text2img',
|
||||
title: 'public work',
|
||||
prompt: 'old public prompt',
|
||||
negative_prompt: null,
|
||||
result_url: '/api/local-storage/gallery/image.webp',
|
||||
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
|
||||
likes_count: 3,
|
||||
is_public: true,
|
||||
status: 'completed',
|
||||
created_at: '2026-05-20T00:00:00.000Z',
|
||||
author_email: 'author@example.com',
|
||||
author_nickname: 'Author',
|
||||
author_display_nickname: 'Author Display',
|
||||
author_avatar_url: null,
|
||||
},
|
||||
updates: [],
|
||||
emails: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
deps: {
|
||||
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
|
||||
updatePrompt: async (workId, prompt) => {
|
||||
state.updates.push({ workId, prompt });
|
||||
state.work = { ...state.work, prompt };
|
||||
return state.work;
|
||||
},
|
||||
sendEmail: async (message) => {
|
||||
state.emails.push(message);
|
||||
if (emailFails) throw new Error('SMTP down');
|
||||
},
|
||||
writeLog: async (entry) => {
|
||||
state.logs.push(entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
|
||||
const baseInput = {
|
||||
workId: '11111111-1111-1111-1111-111111111111',
|
||||
prompt: 'new compliant prompt',
|
||||
emailSubject: '公开作品提示词已调整',
|
||||
emailBody: '你的公开作品提示词已根据平台规范调整。',
|
||||
reasonKey: 'remove_sensitive_words',
|
||||
};
|
||||
|
||||
await runTest('rejects non-public works', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, is_public: false } });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects missing author email', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, author_email: '' } });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects unchanged prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await assert.rejects(
|
||||
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
|
||||
/提示词没有变化/,
|
||||
);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('does not update prompt when email sending fails', async () => {
|
||||
const { deps, state } = createServiceHarness({ emailFails: true });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 1);
|
||||
});
|
||||
|
||||
await runTest('sends email before updating prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.emails.length, 1);
|
||||
assert.equal(state.updates.length, 1);
|
||||
assert.equal(state.updates[0].prompt, 'new compliant prompt');
|
||||
assert.equal(result.work.prompt, 'new compliant prompt');
|
||||
});
|
||||
|
||||
await runTest('writes moderation log metadata without full prompt text', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.logs.length, 1);
|
||||
const logText = JSON.stringify(state.logs[0]);
|
||||
assert.match(logText, /remove_sensitive_words/);
|
||||
assert.doesNotMatch(logText, /old public prompt/);
|
||||
assert.doesNotMatch(logText, /new compliant prompt/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests and confirm RED**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: failure because `src/lib/admin-gallery-prompt-service.ts` does not exist or does not export `updateAdminGalleryPrompt`.
|
||||
|
||||
## Task 2: Service Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin-gallery-prompt-service.ts`
|
||||
- Test: `scripts/test-admin-gallery-prompt-service.mjs`
|
||||
|
||||
- [ ] **Step 1: Implement the tested service**
|
||||
|
||||
Create `src/lib/admin-gallery-prompt-service.ts` with:
|
||||
|
||||
```ts
|
||||
import type { AuthenticatedUser } from '@/lib/session-auth';
|
||||
import { isValidEmail, normalizeEmail } from '@/lib/email-service';
|
||||
|
||||
export type AdminGalleryPromptReasonKey =
|
||||
| 'remove_sensitive_words'
|
||||
| 'improve_wording'
|
||||
| 'remove_private_info'
|
||||
| 'platform_policy_adjustment'
|
||||
| 'custom';
|
||||
|
||||
export interface AdminGalleryPromptWorkRow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
type: string | null;
|
||||
title: string | null;
|
||||
prompt: string | null;
|
||||
negative_prompt?: string | null;
|
||||
result_url: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
likes_count?: number | null;
|
||||
is_public: boolean | null;
|
||||
status: string | null;
|
||||
created_at: string | Date | null;
|
||||
author_email: string | null;
|
||||
author_nickname?: string | null;
|
||||
author_display_nickname?: string | null;
|
||||
author_avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGalleryPromptInput {
|
||||
workId: string;
|
||||
prompt: string;
|
||||
emailSubject: string;
|
||||
emailBody: string;
|
||||
reasonKey?: string;
|
||||
}
|
||||
|
||||
export interface AdminGalleryPromptDeps {
|
||||
admin: AuthenticatedUser;
|
||||
loadWork: (workId: string) => Promise<AdminGalleryPromptWorkRow | null>;
|
||||
updatePrompt: (workId: string, prompt: string) => Promise<AdminGalleryPromptWorkRow>;
|
||||
sendEmail: (message: { to: string; subject: string; body: string; work: AdminGalleryPromptWorkRow; reasonKey: AdminGalleryPromptReasonKey }) => Promise<void>;
|
||||
writeLog: (entry: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export class AdminGalleryPromptError extends Error {
|
||||
constructor(message: string, public status = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminGalleryPrompt(input: AdminGalleryPromptInput, deps: AdminGalleryPromptDeps) {
|
||||
const workId = normalizeUuid(input.workId, '缺少作品 ID');
|
||||
const prompt = normalizeRequiredText(input.prompt, '请填写新的提示词', 8000);
|
||||
const emailSubject = normalizeRequiredText(input.emailSubject, '请填写邮件标题', 120);
|
||||
const emailBody = normalizeRequiredText(input.emailBody, '请填写邮件正文', 5000);
|
||||
const reasonKey = normalizeReasonKey(input.reasonKey);
|
||||
|
||||
const work = await deps.loadWork(workId);
|
||||
if (!work || work.is_public !== true || work.status !== 'completed' || !work.result_url) {
|
||||
throw new AdminGalleryPromptError('作品不存在或不是公开作品', 404);
|
||||
}
|
||||
|
||||
const oldPrompt = String(work.prompt || '').trim();
|
||||
if (oldPrompt === prompt) throw new AdminGalleryPromptError('提示词没有变化', 400);
|
||||
|
||||
const authorEmail = normalizeEmail(work.author_email);
|
||||
if (!isValidEmail(authorEmail)) throw new AdminGalleryPromptError('作者邮箱不可用,无法完成邮件通知', 400);
|
||||
|
||||
await deps.sendEmail({ to: authorEmail, subject: emailSubject, body: emailBody, work, reasonKey });
|
||||
const updated = await deps.updatePrompt(workId, prompt);
|
||||
|
||||
await deps.writeLog({
|
||||
type: 'admin',
|
||||
level: 'info',
|
||||
action: 'admin_gallery_prompt_update',
|
||||
message: '管理员修改公开画廊作品提示词并发送邮件通知',
|
||||
userId: deps.admin.userId,
|
||||
targetType: 'work',
|
||||
targetId: workId,
|
||||
metadata: {
|
||||
workId,
|
||||
authorId: work.user_id,
|
||||
authorEmail,
|
||||
reasonKey,
|
||||
oldPromptLength: oldPrompt.length,
|
||||
newPromptLength: prompt.length,
|
||||
notificationSent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { work: toAdminGalleryPromptWork(updated, authorEmail), notificationSent: true };
|
||||
}
|
||||
|
||||
export function toAdminGalleryPromptWork(row: AdminGalleryPromptWorkRow, authorEmail = normalizeEmail(row.author_email)) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
prompt: row.prompt,
|
||||
negativePrompt: row.negative_prompt || null,
|
||||
url: row.result_url,
|
||||
thumbnailUrl: row.thumbnail_url || null,
|
||||
likes: Number(row.likes_count || 0),
|
||||
authorId: row.user_id,
|
||||
authorEmail,
|
||||
authorNickname: row.author_display_nickname || row.author_nickname || (authorEmail ? authorEmail.split('@')[0] : '匿名用户'),
|
||||
authorAvatarUrl: row.author_avatar_url || null,
|
||||
publishedAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUuid(value: unknown, message: string) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(text)) throw new AdminGalleryPromptError(message, 400);
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeRequiredText(value: unknown, message: string, maxLength: number) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) throw new AdminGalleryPromptError(message, 400);
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeReasonKey(value: unknown): AdminGalleryPromptReasonKey {
|
||||
if (
|
||||
value === 'remove_sensitive_words'
|
||||
|| value === 'improve_wording'
|
||||
|| value === 'remove_private_info'
|
||||
|| value === 'platform_policy_adjustment'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run service tests and confirm GREEN**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: all service tests print `PASS`.
|
||||
|
||||
## Task 3: Admin API Routes
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/admin/gallery/works/route.ts`
|
||||
- Create: `src/app/api/admin/gallery/prompt/route.ts`
|
||||
- Modify if needed: `src/lib/admin-gallery-prompt-service.ts`
|
||||
|
||||
- [ ] **Step 1: Implement `GET /api/admin/gallery/works`**
|
||||
|
||||
Route requirements:
|
||||
|
||||
- Authenticate with `requireAdmin`.
|
||||
- Query `works` joined with `profiles`.
|
||||
- Filter public, completed, result-backed works.
|
||||
- Support `q`, `type`, `limit`, `offset`, `sort`.
|
||||
- Return `works`, `total`, `nextOffset`, `hasMore`.
|
||||
|
||||
- [ ] **Step 2: Implement `PUT /api/admin/gallery/prompt`**
|
||||
|
||||
Route requirements:
|
||||
|
||||
- Authenticate with `requireAdminUser`.
|
||||
- Parse JSON request body.
|
||||
- Call `updateAdminGalleryPrompt`.
|
||||
- Implement real dependencies using `getDbClient`, `sendTemplatedEmail`, `getRequestBaseUrl`, and `writePlatformLog`.
|
||||
- Map `AdminGalleryPromptError.status` to JSON error status.
|
||||
|
||||
- [ ] **Step 3: Run service tests and type check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm run test:admin-gallery-prompt
|
||||
pnpm run ts-check
|
||||
```
|
||||
|
||||
Expected: tests pass and TypeScript reports no errors.
|
||||
|
||||
## Task 4: Admin Console UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/gallery-management-tab.tsx`
|
||||
- Modify: `src/modules/console/pages/console-dashboard-page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the admin gallery tab component**
|
||||
|
||||
Component requirements:
|
||||
|
||||
- Uses `useAuth()` for the bearer token.
|
||||
- Loads `/api/admin/gallery/works`.
|
||||
- Shows search, type filter, refresh, and load-more.
|
||||
- Shows compact public work rows with media preview, author, prompt summary, likes, and edit action.
|
||||
- Provides prompt edit dialog and required email notification dialog.
|
||||
- Provides the four reason templates and custom editing.
|
||||
- Calls `PUT /api/admin/gallery/prompt` only from the email dialog.
|
||||
- Updates the row prompt from the API response on success.
|
||||
|
||||
- [ ] **Step 2: Wire the console shell**
|
||||
|
||||
Modify `ConsoleView`, `CONSOLE_VIEWS`, `VIEW_TITLES`, nav groups, dynamic import list, and `ConsoleContent` to include `gallery`.
|
||||
|
||||
- [ ] **Step 3: Run TypeScript check**
|
||||
|
||||
Run: `pnpm run ts-check`
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
## Task 5: Project Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/codex-miaojing/api-reference.md`
|
||||
- Modify: `docs/codex-miaojing/feature-code-index.md`
|
||||
- Modify: `docs/codex-miaojing/architecture.md` if needed
|
||||
- Modify: `docs/codex-miaojing/bug-location-guide.md` if needed
|
||||
|
||||
- [ ] **Step 1: Document API changes**
|
||||
|
||||
Add `GET /api/admin/gallery/works` and `PUT /api/admin/gallery/prompt`, including auth, request, response, and failure semantics.
|
||||
|
||||
- [ ] **Step 2: Update feature index**
|
||||
|
||||
Add the new admin tab, API routes, service module, and test script to the Admin Console and Gallery sections.
|
||||
|
||||
- [ ] **Step 3: Update architecture/bug guide only for real new operational knowledge**
|
||||
|
||||
If the implementation creates notable operational failure modes, document them. Otherwise leave those files unchanged.
|
||||
|
||||
## Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All touched files.
|
||||
|
||||
- [ ] **Step 1: Run focused tests**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run type check**
|
||||
|
||||
Run: `pnpm run ts-check`
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
- [ ] **Step 3: Run build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
|
||||
Expected: production build completes.
|
||||
|
||||
- [ ] **Step 4: Review git diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no whitespace errors and only intended files changed.
|
||||
|
||||
- [ ] **Step 5: Final deployment note**
|
||||
|
||||
Final response must mention this is a cold-update candidate and production rollout needs backup, PM2 reload/restart, `/api/health`, `/console`, new gallery management smoke check, `/gallery`, and rollback readiness.
|
||||
Reference in New Issue
Block a user