-
-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5d72e60
Add author profile moderation endpoints
admdly b157dae
Fix author timestamp migration defaults
admdly 21872cf
Add author profile fields to extensions v2
admdly 13e6c4b
Address PR review findings on author profiles
admdly ea38e43
Handle pre-existing duplicate owners in the unique-index migration
admdly File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; | ||
| import { databaseError } from "./errors"; | ||
| import { Author, AuthorProfile } from "./interfaces"; | ||
|
|
||
| // Matches the SQLite/D1 message for the idx_authors_owner_unique violation, | ||
| // which is how a lost race between two concurrent first-time PUT /authors/me | ||
| // requests (same caller, different ids) surfaces. | ||
| function isOwnerConflict(message: string | undefined): boolean { | ||
| return !!message && /UNIQUE constraint failed.*owner_user_id/i.test(message); | ||
| } | ||
|
|
||
| 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 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" } | ||
| }; | ||
| } | ||
|
|
||
| let result; | ||
| try { | ||
| 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(); | ||
| } catch (error) { | ||
| if (isOwnerConflict(error instanceof Error ? error.message : "")) { | ||
| return { | ||
| data: null, | ||
| error: { | ||
| message: "You already have a developer profile", | ||
| code: "CONFLICT" | ||
| } | ||
| }; | ||
| } | ||
| throw error; | ||
| } | ||
|
|
||
| if (!result.success) { | ||
| if (isOwnerConflict(result.error)) { | ||
| return { | ||
| data: null, | ||
| error: { | ||
| message: "You already have a developer profile", | ||
| code: "CONFLICT" | ||
| } | ||
| }; | ||
| } | ||
| 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 }; | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
src/services/extensions/v2/db/migrations/0002_add_author_approval.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| 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); | ||
7 changes: 7 additions & 0 deletions
7
src/services/extensions/v2/db/migrations/0003_add_author_profile_fields.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
24 changes: 24 additions & 0 deletions
24
src/services/extensions/v2/db/migrations/0004_add_authors_owner_unique.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| -- v2: enforce one profile per owner at the database level. Without this, | ||
| -- two concurrent first-time PUT /authors/me requests with different ids | ||
| -- (same caller) could both pass the app-level "do I already have a | ||
| -- profile?" check and both insert, leaving the caller with two profiles. | ||
| -- NULL owner_user_id (pre-v2 rows) is exempt: SQLite never treats NULLs as | ||
| -- equal in a unique index, so legacy unowned authors can coexist freely. | ||
| -- | ||
| -- The race this closes predates this migration, so a database may already | ||
| -- have duplicate owner_user_id rows — CREATE UNIQUE INDEX would fail on | ||
| -- those and block every migration after it. Detach ownership (not delete) | ||
| -- from all but the most recently created row per owner first, so the | ||
| -- constraint can always be created; any detached profile becomes unowned, | ||
| -- same as a legacy pre-v2 row, and needs manual reconciliation. | ||
| UPDATE authors | ||
| SET owner_user_id = NULL | ||
| WHERE owner_user_id IS NOT NULL | ||
| AND rowid NOT IN ( | ||
| SELECT MAX(rowid) FROM authors | ||
| WHERE owner_user_id IS NOT NULL | ||
| GROUP BY owner_user_id | ||
| ); | ||
|
|
||
| DROP INDEX IF EXISTS idx_authors_owner; | ||
| CREATE UNIQUE INDEX IF NOT EXISTS idx_authors_owner_unique ON authors(owner_user_id); | ||
|
admdly marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.