b59904fdae
Data model - Properties, rooms (+optional floors), assets (typed custom fields + Zod runtime validator + move history), documents (polymorphic scope) - Projects -> work packages -> tasks -> subtasks - Decision events (scoped to project/property/asset/work_package) - Checklist templates + instances, maintenance schedules (time + usage) with auto-materialized checklists on event recording - Wiki (global + per-project) with revisions + tsvector FTS - Property accounts (utility/meter numbers by kind) - Notifications table + per-user channel prefs Infra - RBAC guards (requireCompany / requireAdmin) - Storage abstraction: LocalDiskStorage (HMAC signed URLs) + S3Storage behind the same interface, switchable via STORAGE_BACKEND - CSV export for assets / maintenance / decisions - QR labels: /api/qr SVG endpoint + printable /assets/[id]/label - Notifications: in-app + SMTP (own server via nodemailer) + Matrix (Client-Server API, per-company room) with opt-in per user - Company switcher + auto-select first company on login UI - Topbar: bell with unread count, theme toggle, name, Sign Out (flat) - Sidebar: main nav + dedicated Admin section (Asset types, Users, Company) - Nested-route tabs on property / project / asset detail pages - Admin UIs for users (invite, role, reset pw, deactivate) and company settings (default currency, Matrix room id) - Custom asset type creation + field-def editor with immutable key/type guard and auto-deprecate when removing a field still referenced Graph - graphify-out/ committed: GRAPH_REPORT.md, graph.html, graph.json
229 lines
6.6 KiB
TypeScript
229 lines
6.6 KiB
TypeScript
import { and, desc, eq, ne, sql } from 'drizzle-orm';
|
|
import { db } from '$lib/server/db/client';
|
|
import { companyUsers, users } from '$lib/server/db/schema/tenancy';
|
|
import { hashPassword } from '$lib/server/auth/password';
|
|
import { normalizeEmail } from '$lib/utils/email';
|
|
|
|
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
|
|
|
|
export interface CompanyUserRow {
|
|
userId: string;
|
|
email: string;
|
|
displayName: string;
|
|
isActive: boolean;
|
|
lastLoginAt: Date | null;
|
|
role: CompanyRole;
|
|
membershipId: string;
|
|
joinedAt: Date;
|
|
}
|
|
|
|
export async function listCompanyUsers(companyId: string): Promise<CompanyUserRow[]> {
|
|
const rows = await db
|
|
.select({
|
|
userId: users.id,
|
|
email: users.email,
|
|
displayName: users.displayName,
|
|
isActive: users.isActive,
|
|
lastLoginAt: users.lastLoginAt,
|
|
role: companyUsers.role,
|
|
membershipId: companyUsers.id,
|
|
joinedAt: companyUsers.joinedAt
|
|
})
|
|
.from(companyUsers)
|
|
.innerJoin(users, eq(users.id, companyUsers.userId))
|
|
.where(eq(companyUsers.companyId, companyId))
|
|
.orderBy(desc(users.isActive), desc(companyUsers.joinedAt));
|
|
return rows as CompanyUserRow[];
|
|
}
|
|
|
|
async function countAdmins(companyId: string): Promise<number> {
|
|
const [{ n }] = await db
|
|
.select({ n: sql<number>`count(*)::int` })
|
|
.from(companyUsers)
|
|
.innerJoin(users, eq(users.id, companyUsers.userId))
|
|
.where(
|
|
and(
|
|
eq(companyUsers.companyId, companyId),
|
|
eq(companyUsers.role, 'admin'),
|
|
eq(users.isActive, true)
|
|
)
|
|
);
|
|
return n;
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
companyId: string;
|
|
email: string;
|
|
displayName: string;
|
|
password: string;
|
|
role: CompanyRole;
|
|
}
|
|
|
|
/**
|
|
* Create a new user and add them to this company at the given role.
|
|
* If a user with this normalized email already exists we reuse them and
|
|
* just add the membership (common invite flow).
|
|
*/
|
|
export async function createUserAndAddToCompany(
|
|
input: CreateUserInput
|
|
): Promise<{ userId: string; created: boolean }> {
|
|
const normalized = normalizeEmail(input.email);
|
|
const displayName = input.displayName.trim();
|
|
if (!displayName) throw new Error('display name is required');
|
|
if (input.password.length < 8) {
|
|
throw new Error('password must be at least 8 characters');
|
|
}
|
|
|
|
return db.transaction(async (tx) => {
|
|
let userId: string;
|
|
let created = false;
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.emailNormalized, normalized))
|
|
.limit(1);
|
|
if (existing) {
|
|
userId = existing.id;
|
|
} else {
|
|
const hash = await hashPassword(input.password);
|
|
const [row] = await tx
|
|
.insert(users)
|
|
.values({
|
|
email: input.email.trim(),
|
|
emailNormalized: normalized,
|
|
displayName,
|
|
passwordHash: hash
|
|
})
|
|
.returning({ id: users.id });
|
|
userId = row.id;
|
|
created = true;
|
|
}
|
|
|
|
const [membership] = await tx
|
|
.select({ id: companyUsers.id })
|
|
.from(companyUsers)
|
|
.where(
|
|
and(eq(companyUsers.companyId, input.companyId), eq(companyUsers.userId, userId))
|
|
)
|
|
.limit(1);
|
|
if (membership) throw new Error('user is already a member of this company');
|
|
|
|
await tx
|
|
.insert(companyUsers)
|
|
.values({ companyId: input.companyId, userId, role: input.role });
|
|
|
|
return { userId, created };
|
|
});
|
|
}
|
|
|
|
export async function updateDisplayName(
|
|
companyId: string,
|
|
userId: string,
|
|
displayName: string
|
|
): Promise<void> {
|
|
await assertMembership(companyId, userId);
|
|
const clean = displayName.trim();
|
|
if (!clean) throw new Error('display name is required');
|
|
await db.update(users).set({ displayName: clean }).where(eq(users.id, userId));
|
|
}
|
|
|
|
export async function setUserRoleInCompany(
|
|
companyId: string,
|
|
userId: string,
|
|
role: CompanyRole
|
|
): Promise<void> {
|
|
await assertMembership(companyId, userId);
|
|
const [current] = await db
|
|
.select({ role: companyUsers.role })
|
|
.from(companyUsers)
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
|
|
.limit(1);
|
|
if (!current) throw new Error('membership not found');
|
|
if (current.role === 'admin' && role !== 'admin') {
|
|
const admins = await countAdmins(companyId);
|
|
if (admins <= 1) {
|
|
throw new Error('Cannot demote the last admin of this company.');
|
|
}
|
|
}
|
|
await db
|
|
.update(companyUsers)
|
|
.set({ role })
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)));
|
|
}
|
|
|
|
export async function removeUserFromCompany(
|
|
companyId: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
await assertMembership(companyId, userId);
|
|
const [current] = await db
|
|
.select({ role: companyUsers.role })
|
|
.from(companyUsers)
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
|
|
.limit(1);
|
|
if (!current) return;
|
|
if (current.role === 'admin') {
|
|
const admins = await countAdmins(companyId);
|
|
if (admins <= 1) {
|
|
throw new Error('Cannot remove the last admin of this company.');
|
|
}
|
|
}
|
|
await db
|
|
.delete(companyUsers)
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)));
|
|
}
|
|
|
|
export async function setUserActive(
|
|
companyId: string,
|
|
userId: string,
|
|
active: boolean
|
|
): Promise<void> {
|
|
await assertMembership(companyId, userId);
|
|
// Don't let the last admin deactivate themselves.
|
|
if (!active) {
|
|
const [m] = await db
|
|
.select({ role: companyUsers.role })
|
|
.from(companyUsers)
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
|
|
.limit(1);
|
|
if (m?.role === 'admin') {
|
|
const admins = await countAdmins(companyId);
|
|
if (admins <= 1) {
|
|
throw new Error('Cannot deactivate the last admin of this company.');
|
|
}
|
|
}
|
|
}
|
|
await db.update(users).set({ isActive: active }).where(eq(users.id, userId));
|
|
}
|
|
|
|
export async function resetUserPassword(
|
|
companyId: string,
|
|
userId: string,
|
|
newPassword: string
|
|
): Promise<void> {
|
|
await assertMembership(companyId, userId);
|
|
if (newPassword.length < 8) {
|
|
throw new Error('password must be at least 8 characters');
|
|
}
|
|
const hash = await hashPassword(newPassword);
|
|
await db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId));
|
|
}
|
|
|
|
async function assertMembership(companyId: string, userId: string): Promise<void> {
|
|
const [row] = await db
|
|
.select({ id: companyUsers.id })
|
|
.from(companyUsers)
|
|
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
|
|
.limit(1);
|
|
if (!row) throw new Error('that user is not in this company');
|
|
}
|
|
|
|
// Exported for the /admin/users page to prevent deactivating yourself while
|
|
// doing it from the UI (backend already enforces; this is UX helper).
|
|
export function isSelf(targetUserId: string, selfUserId: string): boolean {
|
|
return targetUserId === selfUserId;
|
|
}
|
|
|
|
// ne import kept in case we want future checks like "admins other than self"; silence unused.
|
|
void ne;
|