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
+31
View File
@@ -0,0 +1,31 @@
import { pgTable, varchar, text, timestamp, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { notificationKindEnum, pk, fk, createdAt } from './_shared';
export const notifications = pgTable(
'notifications',
{
id: pk(),
userId: fk('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
kind: notificationKindEnum('kind').notNull(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body').notNull(),
// Absolute or relative URL back into the app (e.g. /assets/<id>).
link: varchar('link', { length: 1024 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt()
},
(t) => ({
// Unread-list query: user + read_at IS NULL + ordered by created_at desc.
byUserUnread: index('notifications_by_user_unread').on(t.userId, t.readAt, t.createdAt),
byUserCompany: index('notifications_by_user_company').on(t.userId, t.companyId, t.createdAt)
})
);
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;