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:
2026-04-23 15:18:11 +07:00
parent ad155d6344
commit b59904fdae
387 changed files with 70371 additions and 82 deletions
+228
View File
@@ -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;