Files
wpide-server/src/accounts/teams.ts
T

85 lines
3.4 KiB
TypeScript

/**
* Multi-tenant teams (orgs). An org has an owner and members; the org's
* subscription tier is inherited by all members (so a team owner pays
* once and seats share the tier). Minimal v1: add existing users by
* email; token-based email invites are a later refinement.
*/
import { getDb } from '../db/pool.js';
import { newId } from '../lib/crypto.js';
import { getUserByEmail, getSubscription, type Tier } from './store.js';
export interface Org { id: string; name: string; owner_user_id: string; created_at: string; }
export interface OrgMember { id: string; org_id: string; user_id: string; role: string; created_at: string; email?: string; }
export function createOrg(name: string, ownerUserId: string): Org {
const id = newId();
const db = getDb();
db.prepare('INSERT INTO orgs (id, name, owner_user_id) VALUES (?,?,?)').run(id, name, ownerUserId);
db.prepare('INSERT INTO org_members (id, org_id, user_id, role) VALUES (?,?,?,?)')
.run(newId(), id, ownerUserId, 'owner');
return getOrgById(id)!;
}
export function getOrgById(id: string): Org | null {
return (getDb().prepare('SELECT * FROM orgs WHERE id = ?').get(id) as Org | undefined) ?? null;
}
export function getOrgsForUser(userId: string): (Org & { role: string })[] {
return getDb().prepare(`
SELECT o.*, m.role FROM orgs o
JOIN org_members m ON m.org_id = o.id
WHERE m.user_id = ? ORDER BY o.created_at
`).all(userId) as (Org & { role: string })[];
}
export function isOwner(orgId: string, userId: string): boolean {
const row = getDb().prepare('SELECT role FROM org_members WHERE org_id = ? AND user_id = ?')
.get(orgId, userId) as { role: string } | undefined;
return row?.role === 'owner';
}
export function isMember(orgId: string, userId: string): boolean {
return !!getDb().prepare('SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?').get(orgId, userId);
}
export function getMembers(orgId: string): OrgMember[] {
return getDb().prepare(`
SELECT m.*, u.email FROM org_members m
JOIN users u ON u.id = m.user_id
WHERE m.org_id = ? ORDER BY m.created_at
`).all(orgId) as OrgMember[];
}
export function addMemberByEmail(orgId: string, email: string, role = 'member'): { ok: boolean; error?: string } {
const user = getUserByEmail(email.trim().toLowerCase());
if (!user) return { ok: false, error: 'user_not_found' }; // must have an account (invites: later)
if (isMember(orgId, user.id)) return { ok: false, error: 'already_member' };
getDb().prepare('INSERT INTO org_members (id, org_id, user_id, role) VALUES (?,?,?,?)')
.run(newId(), orgId, user.id, role);
return { ok: true };
}
export function removeMember(orgId: string, userId: string): void {
// Never remove the owner via this path.
getDb().prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ? AND role != 'owner'")
.run(orgId, userId);
}
/**
* The best active tier a user inherits from any org they belong to
* (via that org's owner's subscription). Returns null if none active.
*/
export function inheritedTier(userId: string): Tier | null {
const orgs = getOrgsForUser(userId);
let best: Tier | null = null;
const rank: Record<Tier, number> = { basic: 1, pro: 2, max: 3 };
for (const org of orgs) {
const sub = getSubscription(org.owner_user_id);
if (sub && (sub.status === 'active' || sub.status === 'trialing')) {
if (!best || rank[sub.tier] > rank[best]) best = sub.tier;
}
}
return best;
}