···4040 signIn: "Sign in",
4141 signInHint: "Sign in with your Atmosphere account to publish a profile.",
4242 manageProfile: "Manage profile",
4343+ manageReviews: "Manage reviews",
4444+ chooseAccountType: "Choose account type",
4345 viewProfile: "View profile",
4446 signOut: "Sign out",
4547 avatarAlt: "Account",
···881883 spam: "Spam",
882884 other: "Other",
883885 },
886886+ },
887887+888888+ accountType: {
889889+ title: "How will you use Atmosphere?",
890890+ body: (handle: string): string =>
891891+ `You're signed in as @${handle}. Choose whether this account represents you as a person or a project you want listed in Explore.`,
892892+ userTitle: "I'm a user",
893893+ userBody:
894894+ "Use your Bluesky profile for identity, write reviews, and manage your reviews. No registry profile is created.",
895895+ projectTitle: "I'm a project",
896896+ projectBody:
897897+ "Create and manage a public project profile in Explore with app links, screenshots, and developer details.",
898898+ },
899899+900900+ accountReviews: {
901901+ eyebrow: "User account",
902902+ headline: "Your reviews",
903903+ subhead: (handle: string): string =>
904904+ `Signed in as @${handle}. Your user profile comes from Bluesky; reviews are managed here.`,
905905+ empty: "You haven't reviewed any projects yet.",
906906+ explore: "Explore projects",
907907+ viewProject: "View project",
908908+ delete: "Delete review",
909909+ deleting: "Deleting…",
910910+ deleted: "Review deleted.",
911911+ error: "Couldn't update the review",
884912 },
885913886914 reviews: {
···11+/**
22+ * App-level account classification. A signed-in DID can be a normal
33+ * user (reviewing projects) or a project account (publishing a registry
44+ * profile). The choice is local to this AppView.
55+ */
66+import { withDb } from "./db.ts";
77+import { getProfileByDid } from "./registry.ts";
88+99+export type AccountType = "user" | "project";
1010+1111+export interface AppUserRow {
1212+ did: string;
1313+ handle: string;
1414+ displayName: string | null;
1515+ accountType: AccountType;
1616+ createdAt: number;
1717+ updatedAt: number;
1818+}
1919+2020+interface RawAppUserRow {
2121+ did: string;
2222+ handle: string;
2323+ display_name: string | null;
2424+ account_type: string;
2525+ created_at: number;
2626+ updated_at: number;
2727+}
2828+2929+function normalizeAccountType(value: string): AccountType {
3030+ return value === "project" ? "project" : "user";
3131+}
3232+3333+function rowToAppUser(row: RawAppUserRow): AppUserRow {
3434+ return {
3535+ did: row.did,
3636+ handle: row.handle,
3737+ displayName: row.display_name,
3838+ accountType: normalizeAccountType(row.account_type),
3939+ createdAt: Number(row.created_at),
4040+ updatedAt: Number(row.updated_at),
4141+ };
4242+}
4343+4444+export async function getAppUser(did: string): Promise<AppUserRow | null> {
4545+ return await withDb(async (c) => {
4646+ const r = await c.execute({
4747+ sql: `
4848+ SELECT did, handle, display_name, account_type, created_at, updated_at
4949+ FROM app_user
5050+ WHERE did = ?
5151+ LIMIT 1
5252+ `,
5353+ args: [did],
5454+ });
5555+ const row = r.rows[0] as unknown as RawAppUserRow | undefined;
5656+ return row ? rowToAppUser(row) : null;
5757+ });
5858+}
5959+6060+export async function setAppUserType(input: {
6161+ did: string;
6262+ handle: string;
6363+ displayName?: string | null;
6464+ accountType: AccountType;
6565+}): Promise<AppUserRow> {
6666+ return await withDb(async (c) => {
6767+ const now = Date.now();
6868+ await c.execute({
6969+ sql: `
7070+ INSERT INTO app_user (
7171+ did, handle, display_name, account_type, created_at, updated_at
7272+ ) VALUES (?, ?, ?, ?, ?, ?)
7373+ ON CONFLICT(did) DO UPDATE SET
7474+ handle = excluded.handle,
7575+ display_name = COALESCE(excluded.display_name, app_user.display_name),
7676+ account_type = excluded.account_type,
7777+ updated_at = excluded.updated_at
7878+ `,
7979+ args: [
8080+ input.did,
8181+ input.handle,
8282+ input.displayName ?? null,
8383+ input.accountType,
8484+ now,
8585+ now,
8686+ ],
8787+ });
8888+ const row = await getAppUser(input.did);
8989+ if (!row) throw new Error("app_user_write_failed");
9090+ return row;
9191+ });
9292+}
9393+9494+export async function updateAppUserProfile(input: {
9595+ did: string;
9696+ handle: string;
9797+ displayName?: string | null;
9898+}): Promise<void> {
9999+ await withDb(async (c) => {
100100+ await c.execute({
101101+ sql: `
102102+ UPDATE app_user SET
103103+ handle = ?,
104104+ display_name = ?,
105105+ updated_at = ?
106106+ WHERE did = ?
107107+ `,
108108+ args: [
109109+ input.handle,
110110+ input.displayName?.trim() || null,
111111+ Date.now(),
112112+ input.did,
113113+ ],
114114+ });
115115+ });
116116+}
117117+118118+/**
119119+ * Existing published registry profiles predate account types. Treat those
120120+ * DIDs as projects so old project accounts do not get forced through the
121121+ * new chooser on their next sign-in.
122122+ */
123123+export async function getEffectiveAccountType(
124124+ did: string,
125125+): Promise<AccountType | null> {
126126+ const user = await getAppUser(did);
127127+ if (user) return user.accountType;
128128+ const profile = await getProfileByDid(did, { includeTakenDown: true }).catch(
129129+ () => null,
130130+ );
131131+ return profile ? "project" : null;
132132+}
133133+134134+export async function requiresAccountTypeChoice(did: string): Promise<boolean> {
135135+ return (await getEffectiveAccountType(did)) == null;
136136+}
+20
lib/db.ts
···175175 expires_at INTEGER NOT NULL
176176 )`,
177177 /**
178178+ * App-level account type. OAuth identities can use the registry as
179179+ * plain users (reviews only) or as projects (can publish registry
180180+ * profiles). This is separate from the public `profile` table so
181181+ * regular users never need to create a registry profile.
182182+ */
183183+ `CREATE TABLE IF NOT EXISTS app_user (
184184+ did TEXT PRIMARY KEY,
185185+ handle TEXT NOT NULL,
186186+ display_name TEXT,
187187+ account_type TEXT NOT NULL,
188188+ created_at INTEGER NOT NULL,
189189+ updated_at INTEGER NOT NULL
190190+ )`,
191191+ `CREATE INDEX IF NOT EXISTS app_user_account_type ON app_user(account_type)`,
192192+ /**
178193 * User reports against profiles. Anonymous reports carry a hashed IP
179194 * for dedup + rate-limit; signed-in reports also record the
180195 * reporter's DID. Admin actions write `status`, `admin_notes`,
···396411 table: "profile",
397412 column: "takedown_at",
398413 ddl: "ALTER TABLE profile ADD COLUMN takedown_at INTEGER",
414414+ },
415415+ {
416416+ table: "app_user",
417417+ column: "display_name",
418418+ ddl: "ALTER TABLE app_user ADD COLUMN display_name TEXT",
399419 },
400420 ];
401421 for (const m of additiveColumns) {
+43
lib/reviews.ts
···297297 });
298298}
299299300300+export async function deleteOwnReviewById(
301301+ reviewId: number,
302302+ reviewerDid: string,
303303+): Promise<boolean> {
304304+ return await withDb(async (c) => {
305305+ const r = await c.execute({
306306+ sql: `
307307+ UPDATE review SET
308308+ status = 'removed',
309309+ updated_at = ?,
310310+ removed_at = ?,
311311+ removed_by = ?
312312+ WHERE id = ? AND reviewer_did = ? AND status != 'removed'
313313+ `,
314314+ args: [Date.now(), Date.now(), reviewerDid, reviewId, reviewerDid],
315315+ });
316316+ return Number(r.rowsAffected ?? 0) > 0;
317317+ });
318318+}
319319+300320export async function getReviewSummary(
301321 targetDid: string,
302322): Promise<ReviewSummary> {
···355375 LIMIT ?
356376 `,
357377 args: hasCursor ? [targetDid, opts.cursor!, limit] : [targetDid, limit],
378378+ });
379379+ return r.rows.map((row) => rowToReview(row as unknown as RawReviewRow));
380380+ });
381381+}
382382+383383+export async function listReviewsByReviewer(
384384+ reviewerDid: string,
385385+ opts: { includeRemoved?: boolean } = {},
386386+): Promise<ReviewRow[]> {
387387+ return await withDb(async (c) => {
388388+ const r = await c.execute({
389389+ sql: `
390390+ SELECT r.*, rr.body AS response_body,
391391+ rr.responder_did AS response_responder_did,
392392+ rr.created_at AS response_created_at,
393393+ rr.updated_at AS response_updated_at
394394+ FROM review r
395395+ LEFT JOIN review_response rr ON rr.review_id = r.id
396396+ WHERE r.reviewer_did = ?
397397+ ${opts.includeRemoved ? "" : "AND r.status != 'removed'"}
398398+ ORDER BY r.updated_at DESC
399399+ `,
400400+ args: [reviewerDid],
358401 });
359402 return r.rows.map((row) => rowToReview(row as unknown as RawReviewRow));
360403 });
···11import { createDefine } from "fresh";
22import type { Locale } from "./i18n/locales.ts";
33import type { RememberedAccount } from "./lib/remembered-accounts.ts";
44+import type { AccountType } from "./lib/account-types.ts";
4556export interface SessionUser {
67 did: string;
···1213 locale: Locale;
1314 /** Logged-in registry account, or null when signed out. Set by sessionMiddleware. */
1415 user: SessionUser | null;
1616+ /** Local account role: users manage reviews, projects manage registry profiles. */
1717+ accountType: AccountType | null;
1518 /** Accounts that have completed OAuth on this device, in
1619 * most-recently-used order. Populated by sessionMiddleware so
1720 * routes can hand the list to AccountMenu for the switcher. */