-
-
Notifications
You must be signed in to change notification settings - Fork 0
Add moderation endpoints for author profiles #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; | ||
| import { databaseError } from "./errors"; | ||
| import { Author, AuthorProfile } from "./interfaces"; | ||
|
|
||
| function parseAuthorRow(row: Record<string, unknown>): AuthorProfile { | ||
| return { | ||
| id: row.id as string, | ||
| type: row.type as AuthorProfile["type"], | ||
| name: row.name as string, | ||
| URL: (row.url as string | null) ?? undefined, | ||
| bio: (row.bio as string | null) ?? undefined, | ||
| avatar_url: (row.avatar_url as string | null) ?? undefined, | ||
| contact_email: (row.contact_email as string | null) ?? undefined, | ||
| approved: row.approved_at !== null && row.approved_at !== undefined | ||
| }; | ||
| } | ||
|
|
||
| export class AuthorsDatabase { | ||
| private db: IDatabase; | ||
|
|
||
| constructor(db: IDatabase) { | ||
| this.db = db; | ||
| } | ||
|
|
||
| async getOwn(userId: string): Promise<DatabaseResult<AuthorProfile | null>> { | ||
| try { | ||
| const row = await this.db | ||
| .prepare("SELECT * FROM authors WHERE owner_user_id = ?") | ||
| .bind(userId) | ||
| .first<Record<string, unknown>>(); | ||
| return { data: row ? parseAuthorRow(row) : null, error: null }; | ||
| } catch (error) { | ||
| return databaseError("getOwn", error); | ||
| } | ||
| } | ||
|
|
||
| async upsertOwn( | ||
| userId: string, | ||
| author: Author | ||
| ): Promise<DatabaseResult<AuthorProfile>> { | ||
| try { | ||
| const existingOwn = await this.db | ||
| .prepare("SELECT * FROM authors WHERE owner_user_id = ?") | ||
| .bind(userId) | ||
| .first<Record<string, unknown>>(); | ||
|
|
||
| const existingById = await this.db | ||
| .prepare("SELECT * FROM authors WHERE id = ?") | ||
| .bind(author.id) | ||
| .first<Record<string, unknown>>(); | ||
|
|
||
| if (!existingOwn) { | ||
| if (existingById) { | ||
| return { | ||
| data: null, | ||
| error: { message: "Author id already exists", code: "CONFLICT" } | ||
| }; | ||
| } | ||
|
|
||
| const result = await this.db | ||
| .prepare( | ||
| `INSERT INTO authors (id, type, name, url, bio, avatar_url, contact_email, owner_user_id, approved_at, created_at, updated_at) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` | ||
| ) | ||
| .bind( | ||
| author.id, | ||
| author.type, | ||
| author.name, | ||
| author.URL ?? null, | ||
| author.bio ?? null, | ||
| author.avatar_url ?? null, | ||
| author.contact_email ?? null, | ||
| userId | ||
| ) | ||
| .run(); | ||
|
|
||
| if (!result.success) { | ||
| return databaseError( | ||
| "upsertOwn", | ||
| new Error(result.error || "Database query failed") | ||
| ); | ||
| } | ||
| } else { | ||
| if (author.id !== existingOwn.id) { | ||
| return { | ||
| data: null, | ||
| error: { | ||
| message: "Author id cannot be changed", | ||
| code: "CONFLICT" | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| // approved_at is always cleared here, even if nothing meaningful | ||
| // changed — the reviewed content just got overwritten, so the old | ||
| // approval no longer applies. Not worth diffing old vs. new values. | ||
| const result = await this.db | ||
| .prepare( | ||
| `UPDATE authors SET type = ?, name = ?, url = ?, bio = ?, avatar_url = ?, contact_email = ?, approved_at = NULL, updated_at = CURRENT_TIMESTAMP | ||
| WHERE id = ?` | ||
| ) | ||
| .bind( | ||
| author.type, | ||
| author.name, | ||
| author.URL ?? null, | ||
| author.bio ?? null, | ||
| author.avatar_url ?? null, | ||
| author.contact_email ?? null, | ||
| author.id | ||
| ) | ||
| .run(); | ||
|
|
||
| if (!result.success) { | ||
| return databaseError( | ||
| "upsertOwn", | ||
| new Error(result.error || "Database query failed") | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return this.getById(author.id); | ||
| } catch (error) { | ||
| return databaseError("upsertOwn", error); | ||
| } | ||
| } | ||
|
|
||
| private async getById(id: string): Promise<DatabaseResult<AuthorProfile>> { | ||
| try { | ||
| const row = await this.db | ||
| .prepare("SELECT * FROM authors WHERE id = ?") | ||
| .bind(id) | ||
| .first<Record<string, unknown>>(); | ||
| if (!row) { | ||
| return { | ||
| data: null, | ||
| error: { | ||
| message: `Cannot find author by id: ${id}`, | ||
| code: "NOT_FOUND" | ||
| } | ||
| }; | ||
| } | ||
| return { data: parseAuthorRow(row), error: null }; | ||
| } catch (error) { | ||
| return databaseError("getById", error); | ||
| } | ||
| } | ||
|
|
||
| async listUnapproved(): Promise<DatabaseResult<AuthorProfile[]>> { | ||
| let result; | ||
| try { | ||
| result = await this.db | ||
| .prepare( | ||
| "SELECT * FROM authors WHERE approved_at IS NULL ORDER BY created_at ASC" | ||
| ) | ||
| .all<Record<string, unknown>>(); | ||
| } catch (error) { | ||
| return databaseError("listUnapproved", error); | ||
| } | ||
|
|
||
| if (!result.success) { | ||
| return databaseError( | ||
| "listUnapproved", | ||
| new Error(result.error || "Database query failed") | ||
| ); | ||
| } | ||
|
|
||
| return { | ||
| data: (result.results ?? []).map(parseAuthorRow), | ||
| error: null | ||
| }; | ||
| } | ||
|
|
||
| async approve( | ||
| id: string | ||
| ): Promise<DatabaseResult<{ id: string; approved: true }>> { | ||
| let result; | ||
| try { | ||
| result = await this.db | ||
| .prepare( | ||
| "UPDATE authors SET approved_at = CURRENT_TIMESTAMP WHERE id = ?" | ||
| ) | ||
| .bind(id) | ||
| .run(); | ||
| } catch (error) { | ||
| return databaseError("approve", error); | ||
| } | ||
|
|
||
| if (!result.success) { | ||
| return databaseError( | ||
| "approve", | ||
| new Error(result.error || "Database query failed") | ||
| ); | ||
| } | ||
|
|
||
| if (!result.meta?.changes) { | ||
| return { | ||
| data: null, | ||
| error: { message: `Cannot find author by id: ${id}`, code: "NOT_FOUND" } | ||
| }; | ||
| } | ||
|
|
||
| return { data: { id, approved: true }, error: null }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| -- v2: direct (unmoderated) developer-profile writes, with a moderator-set | ||
| -- "approved" trust flag. Adds to the v1-owned `authors` table. | ||
| -- | ||
| -- SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults (including | ||
| -- CURRENT_TIMESTAMP), so created_at/updated_at are added with a placeholder | ||
| -- default and backfilled immediately after. New rows always set these | ||
| -- explicitly (see authors-database.ts), so the placeholder is never seen | ||
| -- outside of this migration. | ||
|
|
||
| ALTER TABLE authors ADD COLUMN approved_at TEXT; | ||
| ALTER TABLE authors ADD COLUMN created_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Authors created while approving a submission retain the 1970 timestamp, because that insert path omits both new columns. They will sort ahead of every real submission in Prompt for AI agents |
||
| ALTER TABLE authors ADD COLUMN updated_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z'; | ||
|
|
||
| UPDATE authors SET created_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP; | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_authors_approved ON authors(approved_at); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| -- v2: additional developer-profile fields for the public /developer/{id} | ||
| -- page (bio, avatar_url) and moderator/maintainer contact (contact_email, | ||
| -- never exposed on public reads). Adds to the v1-owned `authors` table. | ||
|
|
||
| ALTER TABLE authors ADD COLUMN bio TEXT; | ||
| ALTER TABLE authors ADD COLUMN avatar_url TEXT; | ||
| ALTER TABLE authors ADD COLUMN contact_email TEXT; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3:
getOwn()is never called anywhere in the repository, so this new method adds an unused API surface and untested profile-reading logic. Removing it, or wiring it to an intended GET-own-profile endpoint, would keep the author database surface aligned with the actual routes.Prompt for AI agents