Merge branch 'master' into cli-improvements-1
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "api-server",
|
"name": "@reviq/api-server",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts",
|
"dev": "bun run --hot src/index.ts",
|
||||||
"build": "bun build src/index.ts --outdir dist",
|
"build": "bun build src/index.ts --compile --outfile dist/api-server",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
|
|||||||
@@ -112,36 +112,43 @@ export const createAuthMiddleware = () => {
|
|||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionInfo: Session = session
|
// Build session and auth info based on authentication method
|
||||||
? {
|
let sessionInfo: Session;
|
||||||
|
let authInfo: AuthInfo;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
sessionInfo = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
trustedMode: session.trusted_mode,
|
trustedMode: session.trusted_mode,
|
||||||
createdAt: session.created_at,
|
createdAt: session.created_at,
|
||||||
}
|
|
||||||
: {
|
|
||||||
// For API token auth, create a synthetic session object
|
|
||||||
// We know apiToken exists because userId came from it
|
|
||||||
id: "0",
|
|
||||||
trustedMode: true,
|
|
||||||
createdAt: apiToken?.created_at ?? new Date(),
|
|
||||||
};
|
};
|
||||||
|
authInfo = {
|
||||||
// Build auth info based on authentication method
|
|
||||||
const authInfo: AuthInfo = session
|
|
||||||
? {
|
|
||||||
method: "session",
|
method: "session",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
expiresAt: session.expires_at,
|
expiresAt: session.expires_at,
|
||||||
createdAt: session.created_at,
|
createdAt: session.created_at,
|
||||||
}
|
|
||||||
: {
|
|
||||||
method: "api_token",
|
|
||||||
tokenId: apiToken?.id,
|
|
||||||
tokenName: apiToken?.name,
|
|
||||||
expiresAt: apiToken?.expires_at,
|
|
||||||
lastUsedAt: apiToken?.last_used_at,
|
|
||||||
createdAt: apiToken?.created_at,
|
|
||||||
};
|
};
|
||||||
|
} else if (apiToken) {
|
||||||
|
sessionInfo = {
|
||||||
|
// For API token auth, create a synthetic session object
|
||||||
|
id: "0",
|
||||||
|
trustedMode: true,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
authInfo = {
|
||||||
|
method: "api_token",
|
||||||
|
tokenId: apiToken.id,
|
||||||
|
tokenName: apiToken.name,
|
||||||
|
expiresAt: apiToken.expires_at,
|
||||||
|
lastUsedAt: apiToken.last_used_at,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// This should never happen since we checked userId above
|
||||||
|
throw new ORPCError("UNAUTHORIZED", {
|
||||||
|
message: "Invalid authentication state",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
43
apps/api-server/src/procedures/admin/_routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Admin routes - consolidated exports for os.router()
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { adminAuthCompleteLogin } from "./auth/complete-login.js";
|
||||||
|
import { adminOrgsCreate } from "./orgs/create.js";
|
||||||
|
import { adminOrgsDelete } from "./orgs/delete.js";
|
||||||
|
import { adminOrgsGet } from "./orgs/get.js";
|
||||||
|
import { adminOrgsList } from "./orgs/list.js";
|
||||||
|
import {
|
||||||
|
adminOrgsAddSite,
|
||||||
|
adminOrgsListSites,
|
||||||
|
adminOrgsRemoveSite,
|
||||||
|
} from "./orgs/sites.js";
|
||||||
|
import { adminOrgsUpdate } from "./orgs/update.js";
|
||||||
|
import { adminUsersConfirmEmail } from "./users/confirm-email.js";
|
||||||
|
import { adminUsersCreate } from "./users/create.js";
|
||||||
|
import { adminUsersGet } from "./users/get.js";
|
||||||
|
import { adminUsersList } from "./users/list.js";
|
||||||
|
import { adminUsersUpdate } from "./users/update.js";
|
||||||
|
|
||||||
|
export const adminRoutes = {
|
||||||
|
orgs: {
|
||||||
|
list: adminOrgsList,
|
||||||
|
get: adminOrgsGet,
|
||||||
|
create: adminOrgsCreate,
|
||||||
|
update: adminOrgsUpdate,
|
||||||
|
delete: adminOrgsDelete,
|
||||||
|
listSites: adminOrgsListSites,
|
||||||
|
addSite: adminOrgsAddSite,
|
||||||
|
removeSite: adminOrgsRemoveSite,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
list: adminUsersList,
|
||||||
|
get: adminUsersGet,
|
||||||
|
create: adminUsersCreate,
|
||||||
|
update: adminUsersUpdate,
|
||||||
|
confirmEmail: adminUsersConfirmEmail,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
completeLogin: adminAuthCompleteLogin,
|
||||||
|
},
|
||||||
|
};
|
||||||
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
32
apps/api-server/src/procedures/admin/auth/complete-login.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* admin.auth.completeLogin - Complete pending login request (dev helper)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const loginRequest = await context.db
|
||||||
|
.selectFrom("login_requests")
|
||||||
|
.where("email", "=", input.email.toLowerCase())
|
||||||
|
.where("completed_at", "is", null)
|
||||||
|
.where("expires_at", ">", new Date())
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!loginRequest) {
|
||||||
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
message: "No pending login request found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.updateTable("login_requests")
|
||||||
|
.set({ completed_at: new Date() })
|
||||||
|
.where("id", "=", loginRequest.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
35
apps/api-server/src/procedures/admin/helpers.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Admin procedure helpers - shared transformation functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { OrgSites, Orgs, Users } from "@reviq/db-schema";
|
||||||
|
import type { Selectable } from "kysely";
|
||||||
|
|
||||||
|
/** Transform org record to API response format */
|
||||||
|
export const toOrgResponse = (org: Selectable<Orgs>) => ({
|
||||||
|
id: org.id,
|
||||||
|
slug: org.slug,
|
||||||
|
displayName: org.display_name,
|
||||||
|
logoUrl: org.logo_url,
|
||||||
|
createdAt: org.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Transform user record to API response format */
|
||||||
|
export const toUserResponse = (user: Selectable<Users>) => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
fullName: user.full_name,
|
||||||
|
phoneNumber: user.phone_number,
|
||||||
|
avatarUrl: user.avatar_url,
|
||||||
|
emailVerified: user.email_verified_at !== null,
|
||||||
|
needsSetup: user.display_name === null,
|
||||||
|
isSuperuser: user.is_superuser,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Transform site record to API response format */
|
||||||
|
export const toSiteResponse = (site: Selectable<OrgSites>) => ({
|
||||||
|
id: site.id,
|
||||||
|
domain: site.domain,
|
||||||
|
createdAt: site.created_at,
|
||||||
|
});
|
||||||
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
58
apps/api-server/src/procedures/admin/orgs/create.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.create - Create organization with owner
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminOrgsCreate = os.admin.orgs.create
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, displayName, ownerEmail } = input;
|
||||||
|
|
||||||
|
// Find owner user by email (outside transaction - read-only)
|
||||||
|
const owner = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("email", "=", ownerEmail.toLowerCase())
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!owner) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create org and owner membership in transaction (with race condition protection)
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Check for existing org inside transaction to prevent race condition
|
||||||
|
const existingOrg = await trx
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existingOrg) {
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "Organization with this slug already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrg = await trx
|
||||||
|
.insertInto("orgs")
|
||||||
|
.values({
|
||||||
|
slug,
|
||||||
|
display_name: displayName,
|
||||||
|
})
|
||||||
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("org_members")
|
||||||
|
.values({
|
||||||
|
org_id: newOrg.id,
|
||||||
|
user_id: owner.id,
|
||||||
|
role: "owner",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slug };
|
||||||
|
});
|
||||||
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
36
apps/api-server/src/procedures/admin/orgs/delete.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.delete - Delete organization and all related records
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminOrgsDelete = os.admin.orgs.delete
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
// Delete org and related records in transaction
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
const org = await trx
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_invites")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.execute();
|
||||||
|
await trx.deleteFrom("org_sites").where("org_id", "=", org.id).execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom("org_members")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.execute();
|
||||||
|
await trx.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
22
apps/api-server/src/procedures/admin/orgs/get.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.get - Get organization by slug
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
|
export const adminOrgsGet = os.admin.orgs.get
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const org = await context.db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", input.slug)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
return toOrgResponse(org);
|
||||||
|
});
|
||||||
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
14
apps/api-server/src/procedures/admin/orgs/list.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.list - List all organizations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
|
export const adminOrgsList = os.admin.orgs.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||||
|
return orgs.map(toOrgResponse);
|
||||||
|
});
|
||||||
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
97
apps/api-server/src/procedures/admin/orgs/sites.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.listSites, admin.orgs.addSite, admin.orgs.removeSite
|
||||||
|
* Site management for organizations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
import { toSiteResponse } from "../helpers.js";
|
||||||
|
|
||||||
|
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug } = input;
|
||||||
|
|
||||||
|
const org = await context.db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sites = await context.db
|
||||||
|
.selectFrom("org_sites")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return sites.map(toSiteResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, domain } = input;
|
||||||
|
|
||||||
|
// Use transaction to prevent race condition on site creation
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
const org = await trx
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if site already exists (inside transaction)
|
||||||
|
const existingSite = await trx
|
||||||
|
.selectFrom("org_sites")
|
||||||
|
.where("domain", "=", domain)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existingSite) {
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "Site with this domain already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insertInto("org_sites")
|
||||||
|
.values({
|
||||||
|
org_id: org.id,
|
||||||
|
domain,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, domain } = input;
|
||||||
|
|
||||||
|
const org = await context.db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.deleteFrom("org_sites")
|
||||||
|
.where("org_id", "=", org.id)
|
||||||
|
.where("domain", "=", domain)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
50
apps/api-server/src/procedures/admin/orgs/update.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* admin.orgs.update - Update organization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminOrgsUpdate = os.admin.orgs.update
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
|
// Check if there are actual updates to make
|
||||||
|
if (displayName === undefined && logoUrl === undefined) {
|
||||||
|
// Verify org exists even for no-op
|
||||||
|
const org = await context.db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<{
|
||||||
|
display_name: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
updated_at: Date;
|
||||||
|
}> = { updated_at: new Date() };
|
||||||
|
|
||||||
|
if (displayName !== undefined) {
|
||||||
|
updates.display_name = displayName;
|
||||||
|
}
|
||||||
|
if (logoUrl !== undefined) {
|
||||||
|
updates.logo_url = logoUrl || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("orgs")
|
||||||
|
.set(updates)
|
||||||
|
.where("slug", "=", slug)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
24
apps/api-server/src/procedures/admin/users/confirm-email.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* admin.users.confirmEmail - Confirm a user's email (used by CLI)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("users")
|
||||||
|
.set({
|
||||||
|
email_verified_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.where("email", "=", input.email.toLowerCase())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
63
apps/api-server/src/procedures/admin/users/create.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* admin.users.create - Create passwordless user, optionally add to org
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminUsersCreate = os.admin.users.create
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { email, name, orgSlug, orgRole } = input;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
// If orgSlug provided, verify org exists (outside transaction - read-only)
|
||||||
|
let orgId: number | undefined;
|
||||||
|
if (orgSlug) {
|
||||||
|
const org = await context.db
|
||||||
|
.selectFrom("orgs")
|
||||||
|
.where("slug", "=", orgSlug)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!org) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
|
}
|
||||||
|
orgId = org.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in transaction (with race condition protection)
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Check for existing user inside transaction to prevent race condition
|
||||||
|
const existingUser = await trx
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("email", "=", normalizedEmail)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ORPCError("CONFLICT", {
|
||||||
|
message: "User with this email already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await trx
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
email: normalizedEmail,
|
||||||
|
display_name: name ?? null,
|
||||||
|
})
|
||||||
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
if (orgId !== undefined) {
|
||||||
|
await trx
|
||||||
|
.insertInto("org_members")
|
||||||
|
.values({
|
||||||
|
org_id: orgId,
|
||||||
|
user_id: newUser.id,
|
||||||
|
role: orgRole ?? "member",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
22
apps/api-server/src/procedures/admin/users/get.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* admin.users.get - Get user by email
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
|
export const adminUsersGet = os.admin.users.get
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const user = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("email", "=", input.email.toLowerCase())
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!user) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
|
}
|
||||||
|
return toUserResponse(user);
|
||||||
|
});
|
||||||
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
14
apps/api-server/src/procedures/admin/users/list.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* admin.users.list - List all users
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
|
export const adminUsersList = os.admin.users.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||||
|
return users.map(toUserResponse);
|
||||||
|
});
|
||||||
48
apps/api-server/src/procedures/admin/users/update.ts
Normal file
48
apps/api-server/src/procedures/admin/users/update.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* admin.users.update - Update user properties (e.g., isSuperuser)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||||
|
|
||||||
|
export const adminUsersUpdate = os.admin.users.update
|
||||||
|
.use(authMiddleware)
|
||||||
|
.use(superuserMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { email, isSuperuser } = input;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
// Check if there are actual updates to make
|
||||||
|
if (isSuperuser === undefined) {
|
||||||
|
// Verify user exists even for no-op
|
||||||
|
const user = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("email", "=", normalizedEmail)
|
||||||
|
.select(["id"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!user) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent superuser from demoting themselves
|
||||||
|
if (!isSuperuser && normalizedEmail === context.user.email.toLowerCase()) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Cannot remove your own superuser status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("users")
|
||||||
|
.set({
|
||||||
|
is_superuser: isSuperuser,
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.where("email", "=", normalizedEmail)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -110,35 +110,43 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
|
|||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionInfo: Session = session
|
// Build session and auth info based on authentication method
|
||||||
? {
|
let sessionInfo: Session;
|
||||||
|
let authInfo: AuthInfo;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
sessionInfo = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
trustedMode: session.trusted_mode,
|
trustedMode: session.trusted_mode,
|
||||||
createdAt: session.created_at,
|
createdAt: session.created_at,
|
||||||
}
|
|
||||||
: {
|
|
||||||
// For API token auth, create a synthetic session object
|
|
||||||
id: "0",
|
|
||||||
trustedMode: true,
|
|
||||||
createdAt: apiToken?.created_at ?? new Date(),
|
|
||||||
};
|
};
|
||||||
|
authInfo = {
|
||||||
// Build auth info based on authentication method
|
|
||||||
const authInfo: AuthInfo = session
|
|
||||||
? {
|
|
||||||
method: "session",
|
method: "session",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
expiresAt: session.expires_at,
|
expiresAt: session.expires_at,
|
||||||
createdAt: session.created_at,
|
createdAt: session.created_at,
|
||||||
}
|
|
||||||
: {
|
|
||||||
method: "api_token",
|
|
||||||
tokenId: apiToken?.id,
|
|
||||||
tokenName: apiToken?.name,
|
|
||||||
expiresAt: apiToken?.expires_at,
|
|
||||||
lastUsedAt: apiToken?.last_used_at,
|
|
||||||
createdAt: apiToken?.created_at,
|
|
||||||
};
|
};
|
||||||
|
} else if (apiToken) {
|
||||||
|
sessionInfo = {
|
||||||
|
// For API token auth, create a synthetic session object
|
||||||
|
id: "0",
|
||||||
|
trustedMode: true,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
authInfo = {
|
||||||
|
method: "api_token",
|
||||||
|
tokenId: apiToken.id,
|
||||||
|
tokenName: apiToken.name,
|
||||||
|
expiresAt: apiToken.expires_at,
|
||||||
|
lastUsedAt: apiToken.last_used_at,
|
||||||
|
createdAt: apiToken.created_at,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// This should never happen since we checked userId above
|
||||||
|
throw new ORPCError("UNAUTHORIZED", {
|
||||||
|
message: "Invalid authentication state",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { adminRoutes } from "./procedures/admin/_routes.js";
|
||||||
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
||||||
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
||||||
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js";
|
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js";
|
||||||
@@ -241,91 +242,6 @@ const setupProfile = os.me.setupProfile
|
|||||||
// - invitesList, invitesCreate, invitesCancel, invitesAccept
|
// - invitesList, invitesCreate, invitesCancel, invitesAccept
|
||||||
// - sitesList
|
// - sitesList
|
||||||
|
|
||||||
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
|
|
||||||
const adminOrgsList = os.admin.orgs.list
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsGet = os.admin.orgs.get.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsCreate = os.admin.orgs.create
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsUpdate = os.admin.orgs.update
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsDelete = os.admin.orgs.delete
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsListSites = os.admin.orgs.listSites
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsAddSite = os.admin.orgs.addSite
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin users procedures
|
|
||||||
const adminUsersList = os.admin.users.list
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminUsersGet = os.admin.users.get
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminUsersCreate = os.admin.users.create
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminUsersUpdate = os.admin.users.update
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin auth procedures
|
|
||||||
const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the router
|
// Build the router
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
auth: {
|
auth: {
|
||||||
@@ -389,26 +305,5 @@ export const router = os.router({
|
|||||||
list: sitesList,
|
list: sitesList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
admin: adminRoutes,
|
||||||
orgs: {
|
|
||||||
list: adminOrgsList,
|
|
||||||
get: adminOrgsGet,
|
|
||||||
create: adminOrgsCreate,
|
|
||||||
update: adminOrgsUpdate,
|
|
||||||
delete: adminOrgsDelete,
|
|
||||||
listSites: adminOrgsListSites,
|
|
||||||
addSite: adminOrgsAddSite,
|
|
||||||
removeSite: adminOrgsRemoveSite,
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
list: adminUsersList,
|
|
||||||
get: adminUsersGet,
|
|
||||||
create: adminUsersCreate,
|
|
||||||
update: adminUsersUpdate,
|
|
||||||
confirmEmail: adminUsersConfirmEmail,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
completeLogin: adminAuthCompleteLogin,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"reviq": "./dist/index.js",
|
"reviq": "./dist/reviq",
|
||||||
"__reviq_bash_complete": "./dist/bash-complete.js"
|
"__reviq_bash_complete": "./dist/bash-complete"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/bin/reviq.ts --outfile dist/index.js --target bun && bun build src/bin/bash-complete.ts --outfile dist/bash-complete.js --target bun",
|
"build": "bun build src/bin/reviq.ts --compile --outfile dist/reviq && bun build src/bin/bash-complete.ts --compile --outfile dist/bash-complete",
|
||||||
"cli": "bun run src/bin/reviq.ts",
|
"cli": "bun run src/bin/reviq.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/api-server": {
|
"apps/api-server": {
|
||||||
"name": "api-server",
|
"name": "@reviq/api-server",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
@@ -372,6 +372,8 @@
|
|||||||
|
|
||||||
"@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"],
|
"@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"],
|
||||||
|
|
||||||
|
"@reviq/api-server": ["@reviq/api-server@workspace:apps/api-server"],
|
||||||
|
|
||||||
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
|
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
|
||||||
|
|
||||||
"@reviq/db": ["@reviq/db@workspace:packages/db"],
|
"@reviq/db": ["@reviq/db@workspace:packages/db"],
|
||||||
@@ -538,8 +540,6 @@
|
|||||||
|
|
||||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"api-server": ["api-server@workspace:apps/api-server"],
|
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ publisher-dashboard/
|
|||||||
│ │ ├── index.ts # Server entry point (Bun.serve)
|
│ │ ├── index.ts # Server entry point (Bun.serve)
|
||||||
│ │ ├── router.ts
|
│ │ ├── router.ts
|
||||||
│ │ ├── procedures/
|
│ │ ├── procedures/
|
||||||
|
│ │ │ ├── base.ts # Middleware (auth, superuser, loginRequest)
|
||||||
|
│ │ │ ├── auth/ # Auth procedures
|
||||||
|
│ │ │ ├── me/ # User self-management procedures
|
||||||
|
│ │ │ └── admin/ # Superuser-only procedures
|
||||||
|
│ │ │ ├── _routes.ts # Consolidated admin route exports
|
||||||
|
│ │ │ ├── helpers.ts # Shared transform functions
|
||||||
|
│ │ │ ├── auth/
|
||||||
|
│ │ │ ├── orgs/
|
||||||
|
│ │ │ └── users/
|
||||||
│ │ └── middleware/
|
│ │ └── middleware/
|
||||||
│ ├── publisher-dashboard/ # SvelteKit frontend
|
│ ├── publisher-dashboard/ # SvelteKit frontend
|
||||||
│ │ ├── package.json
|
│ │ ├── package.json
|
||||||
@@ -2306,12 +2315,18 @@ _Can run parallel to H after F1 is done_
|
|||||||
|
|
||||||
_Depends on: D1 (auth middleware)_
|
_Depends on: D1 (auth middleware)_
|
||||||
|
|
||||||
- [ ] **J1**: Implement org middleware (slug lookup, membership check)
|
- [x] **J1**: Implement org middleware (slug lookup, membership check)
|
||||||
- [ ] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
|
- [x] **J2**: Implement `orgs.list`, `orgs.create`, `orgs.get`
|
||||||
- [ ] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
|
- [x] **J3**: Implement `orgs.update`, `orgs.delete`, `orgs.leave`
|
||||||
- [ ] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
|
- [x] **J4**: Implement `orgs.members.list`, `orgs.members.updateRole`, `orgs.members.remove`
|
||||||
- [ ] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
|
- [x] **J5**: Implement `orgs.invites.list`, `orgs.invites.create`, `orgs.invites.cancel`, `orgs.invites.accept`
|
||||||
- [ ] **J6**: Implement `orgs.sites.list`
|
- [x] **J6**: Implement `orgs.sites.list`
|
||||||
|
|
||||||
|
_Implementation notes:_
|
||||||
|
- Files in `procedures/orgs/` with `index.ts` for consolidated exports
|
||||||
|
- Helper functions in `helpers.ts`: `lookupOrgBySlug`, `getMembership`, `requireRole`, `countOwners`
|
||||||
|
- Race conditions prevented via Kysely transactions for owner count checks
|
||||||
|
- Privilege escalation prevented: only owners can invite new owners
|
||||||
|
|
||||||
#### Workstream K: Admin Procedures (Backend)
|
#### Workstream K: Admin Procedures (Backend)
|
||||||
|
|
||||||
@@ -2324,6 +2339,12 @@ _Can run parallel to J2-J6_
|
|||||||
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
|
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
|
||||||
- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper)
|
- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper)
|
||||||
|
|
||||||
|
_Implementation notes:_
|
||||||
|
- Files in `procedures/admin/` with `_routes.ts` for consolidated exports
|
||||||
|
- Helper functions in `helpers.ts`: `toOrgResponse`, `toUserResponse`, `toSiteResponse`
|
||||||
|
- Race conditions prevented via transaction-scoped existence checks
|
||||||
|
- Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status
|
||||||
|
|
||||||
#### Workstream L: Org Pages (Frontend)
|
#### Workstream L: Org Pages (Frontend)
|
||||||
|
|
||||||
_Depends on: J1-J6, C3_
|
_Depends on: J1-J6, C3_
|
||||||
|
|||||||
Reference in New Issue
Block a user