Merge branch 'workstream-k'
This commit is contained in:
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 { Selectable } from "kysely";
|
||||||
|
import type { Orgs, OrgSites, Users } from "@reviq/db-schema";
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
});
|
||||||
51
apps/api-server/src/procedures/admin/users/update.ts
Normal file
51
apps/api-server/src/procedures/admin/users/update.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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 === false &&
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,11 +9,8 @@ import { resendVerificationEmail as resendVerificationHandler } from "./procedur
|
|||||||
import { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js";
|
import { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js";
|
||||||
import { signup as signupHandler } from "./procedures/auth/signup.js";
|
import { signup as signupHandler } from "./procedures/auth/signup.js";
|
||||||
import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js";
|
import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js";
|
||||||
import {
|
import { authMiddleware, loginRequestMiddleware, os } from "./procedures/base.js";
|
||||||
authMiddleware,
|
import { adminRoutes } from "./procedures/admin/_routes.js";
|
||||||
loginRequestMiddleware,
|
|
||||||
os,
|
|
||||||
} from "./procedures/base.js";
|
|
||||||
import { meDelete } from "./procedures/me/delete.js";
|
import { meDelete } from "./procedures/me/delete.js";
|
||||||
import {
|
import {
|
||||||
getDeviceInfo,
|
getDeviceInfo,
|
||||||
@@ -207,91 +204,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: {
|
||||||
@@ -354,26 +266,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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2324,6 +2333,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