Phases 1-5 + rooms/floors, accounts, custom types, users, notifications
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
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user