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
204 changes: 204 additions & 0 deletions src/services/extensions/v2/authors-database.ts
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>> {

Copy link
Copy Markdown
Contributor

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
Check if this issue is valid — if so, understand the root cause and fix it. At src/services/extensions/v2/authors-database.ts, line 22:

<comment>`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.</comment>

<file context>
@@ -0,0 +1,184 @@
+    this.db = db;
+  }
+
+  async getOwn(userId: string): Promise<DatabaseResult<AuthorProfile | null>> {
+    try {
+      const row = await this.db
</file context>

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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 /authors/unapproved; set timestamps in that insert path (or use a trigger) so the migration's placeholder cannot persist.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/services/extensions/v2/db/migrations/0002_add_author_approval.sql, line 11:

<comment>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 `/authors/unapproved`; set timestamps in that insert path (or use a trigger) so the migration's placeholder cannot persist.</comment>

<file context>
@@ -1,8 +1,16 @@
 ALTER TABLE authors ADD COLUMN approved_at TEXT;
-ALTER TABLE authors ADD COLUMN created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;
-ALTER TABLE authors ADD COLUMN updated_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;
+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';
+
</file context>

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;
Loading
Loading