create-protobase 0.23.13 → 0.23.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-preset.js +25 -0
- package/dist/create-project.js +7 -6
- package/dist/index.js +19 -9
- package/dist/next-steps.js +8 -2
- package/dist/presets.js +17 -0
- package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
- package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
- package/dist/templates/erp/.env.example +14 -0
- package/dist/templates/erp/AGENTS.md +24 -0
- package/dist/templates/erp/auth/auth.ts +68 -0
- package/dist/templates/erp/components/orders-per-day.tsx +21 -0
- package/dist/templates/erp/config/bottom-bar.ts +9 -0
- package/dist/templates/erp/config/categories/data.ts +20 -0
- package/dist/templates/erp/config/categories/ui.ts +11 -0
- package/dist/templates/erp/config/companies/data.ts +23 -0
- package/dist/templates/erp/config/companies/ui.ts +22 -0
- package/dist/templates/erp/config/company_tags/data.ts +16 -0
- package/dist/templates/erp/config/company_tags/ui.ts +7 -0
- package/dist/templates/erp/config/countries/data.ts +13 -0
- package/dist/templates/erp/config/countries/ui.ts +6 -0
- package/dist/templates/erp/config/currencies/data.ts +14 -0
- package/dist/templates/erp/config/currencies/ui.ts +6 -0
- package/dist/templates/erp/config/emp_master/data.ts +19 -0
- package/dist/templates/erp/config/emp_master/ui.ts +7 -0
- package/dist/templates/erp/config/index.ts +42 -0
- package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
- package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
- package/dist/templates/erp/config/invoices/data.ts +26 -0
- package/dist/templates/erp/config/invoices/ui.ts +38 -0
- package/dist/templates/erp/config/jobs.test.ts +23 -0
- package/dist/templates/erp/config/jobs.ts +29 -0
- package/dist/templates/erp/config/locations/data.ts +18 -0
- package/dist/templates/erp/config/locations/ui.ts +6 -0
- package/dist/templates/erp/config/order_lines/data.ts +20 -0
- package/dist/templates/erp/config/order_lines/ui.ts +7 -0
- package/dist/templates/erp/config/orders/data.ts +27 -0
- package/dist/templates/erp/config/orders/ui.ts +41 -0
- package/dist/templates/erp/config/organizations/data.ts +17 -0
- package/dist/templates/erp/config/organizations/ui.ts +6 -0
- package/dist/templates/erp/config/overview/page.tsx +80 -0
- package/dist/templates/erp/config/pages.test.ts +14 -0
- package/dist/templates/erp/config/people/data.ts +21 -0
- package/dist/templates/erp/config/people/ui.ts +6 -0
- package/dist/templates/erp/config/products/data.ts +25 -0
- package/dist/templates/erp/config/products/ui.ts +6 -0
- package/dist/templates/erp/config/roles.test.ts +56 -0
- package/dist/templates/erp/config/roles.ts +54 -0
- package/dist/templates/erp/config/shell.test.ts +24 -0
- package/dist/templates/erp/config/stock_levels/data.ts +16 -0
- package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
- package/dist/templates/erp/config/stock_moves/data.ts +22 -0
- package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
- package/dist/templates/erp/config/tags/data.ts +17 -0
- package/dist/templates/erp/config/tags/ui.ts +6 -0
- package/dist/templates/erp/config/user-menu.ts +9 -0
- package/dist/templates/erp/config/users/data.ts +19 -0
- package/dist/templates/erp/config/users/ui.ts +9 -0
- package/dist/templates/erp/config/warehouses/data.ts +19 -0
- package/dist/templates/erp/config/warehouses/ui.ts +6 -0
- package/dist/templates/erp/data.ts +3 -0
- package/dist/templates/erp/db/connection.ts +7 -0
- package/dist/templates/erp/db/drop.ts +9 -0
- package/dist/templates/erp/db/kysely.ts +13 -0
- package/dist/templates/erp/db/migrations/001_core.sql +48 -0
- package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
- package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
- package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
- package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
- package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
- package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
- package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
- package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
- package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
- package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
- package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
- package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
- package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
- package/dist/templates/erp/package.json +38 -0
- package/dist/templates/erp/protobase.config.ts +22 -0
- package/dist/templates/erp/protobase.ui.tsx +14 -0
- package/dist/templates/erp/seed/auth-organizations.ts +11 -0
- package/dist/templates/erp/seed/base-organization.test.ts +17 -0
- package/dist/templates/erp/seed/base-organization.ts +10 -0
- package/dist/templates/erp/seed/base.ts +22 -0
- package/dist/templates/erp/seed/builders/categories.ts +18 -0
- package/dist/templates/erp/seed/builders/company.ts +61 -0
- package/dist/templates/erp/seed/builders/invoice.ts +31 -0
- package/dist/templates/erp/seed/builders/order.ts +66 -0
- package/dist/templates/erp/seed/builders/product.ts +46 -0
- package/dist/templates/erp/seed/calendar.ts +60 -0
- package/dist/templates/erp/seed/cli.ts +54 -0
- package/dist/templates/erp/seed/copy.ts +22 -0
- package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
- package/dist/templates/erp/seed/data/layout.ts +19 -0
- package/dist/templates/erp/seed/data/names.ts +44 -0
- package/dist/templates/erp/seed/data/reference.ts +53 -0
- package/dist/templates/erp/seed/format.ts +24 -0
- package/dist/templates/erp/seed/maintenance.ts +63 -0
- package/dist/templates/erp/seed/rng.ts +36 -0
- package/dist/templates/erp/seed/scales.ts +9 -0
- package/dist/templates/erp/seed/steps/catalog.ts +34 -0
- package/dist/templates/erp/seed/steps/crm.ts +62 -0
- package/dist/templates/erp/seed/steps/hr.ts +26 -0
- package/dist/templates/erp/seed/steps/ledger.ts +88 -0
- package/dist/templates/erp/seed/steps/reference.ts +24 -0
- package/dist/templates/erp/seed/steps/sales.ts +58 -0
- package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
- package/dist/templates/erp/seed/world.ts +86 -0
- package/dist/templates/erp/vitest.config.ts +4 -0
- package/dist/templates/real-estate/.env.example +9 -0
- package/dist/templates/real-estate/AGENTS.md +24 -0
- package/dist/templates/real-estate/_.gitignore +3 -0
- package/dist/templates/real-estate/_bunfig.toml +4 -0
- package/dist/templates/real-estate/auth/auth.ts +36 -0
- package/dist/templates/real-estate/config/amenities/data.ts +12 -0
- package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
- package/dist/templates/real-estate/config/arrears/data.ts +19 -0
- package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
- package/dist/templates/real-estate/config/deposits/data.ts +21 -0
- package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
- package/dist/templates/real-estate/config/index.ts +41 -0
- package/dist/templates/real-estate/config/inspections/data.ts +22 -0
- package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
- package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
- package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
- package/dist/templates/real-estate/config/leases/data.ts +29 -0
- package/dist/templates/real-estate/config/leases/ui.ts +33 -0
- package/dist/templates/real-estate/config/organizations/data.ts +17 -0
- package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
- package/dist/templates/real-estate/config/overview/page.tsx +57 -0
- package/dist/templates/real-estate/config/owners/data.ts +22 -0
- package/dist/templates/real-estate/config/owners/ui.ts +17 -0
- package/dist/templates/real-estate/config/pages.test.ts +49 -0
- package/dist/templates/real-estate/config/payments/data.ts +20 -0
- package/dist/templates/real-estate/config/payments/ui.ts +14 -0
- package/dist/templates/real-estate/config/properties/data.ts +26 -0
- package/dist/templates/real-estate/config/properties/ui.ts +21 -0
- package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
- package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
- package/dist/templates/real-estate/config/roles.test.ts +49 -0
- package/dist/templates/real-estate/config/roles.ts +45 -0
- package/dist/templates/real-estate/config/shell.test.ts +18 -0
- package/dist/templates/real-estate/config/tenants/data.ts +23 -0
- package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
- package/dist/templates/real-estate/config/tickets/data.ts +33 -0
- package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
- package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
- package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
- package/dist/templates/real-estate/config/units/data.ts +24 -0
- package/dist/templates/real-estate/config/units/ui.ts +41 -0
- package/dist/templates/real-estate/config/user-menu.ts +9 -0
- package/dist/templates/real-estate/config/users/data.ts +19 -0
- package/dist/templates/real-estate/config/users/ui.ts +11 -0
- package/dist/templates/real-estate/config/valuations/data.ts +19 -0
- package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
- package/dist/templates/real-estate/config/vendors/data.ts +21 -0
- package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
- package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
- package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
- package/dist/templates/real-estate/db/connection.ts +7 -0
- package/dist/templates/real-estate/db/drop.ts +9 -0
- package/dist/templates/real-estate/db/kysely.ts +13 -0
- package/dist/templates/real-estate/db/migrate.ts +30 -0
- package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
- package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
- package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
- package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
- package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
- package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
- package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
- package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
- package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
- package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
- package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
- package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
- package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
- package/dist/templates/real-estate/node-types.d.ts +1 -0
- package/dist/templates/real-estate/package.json +38 -0
- package/dist/templates/real-estate/protobase.config.ts +5 -0
- package/dist/templates/real-estate/protobase.ui.tsx +16 -0
- package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
- package/dist/templates/real-estate/seed/base-organization.ts +10 -0
- package/dist/templates/real-estate/seed/base.ts +18 -0
- package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
- package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
- package/dist/templates/real-estate/seed/builders/property.ts +98 -0
- package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
- package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
- package/dist/templates/real-estate/seed/calendar.ts +77 -0
- package/dist/templates/real-estate/seed/cli.ts +50 -0
- package/dist/templates/real-estate/seed/copy.ts +22 -0
- package/dist/templates/real-estate/seed/data/names.ts +31 -0
- package/dist/templates/real-estate/seed/data/reference.ts +63 -0
- package/dist/templates/real-estate/seed/format.ts +24 -0
- package/dist/templates/real-estate/seed/iban.ts +15 -0
- package/dist/templates/real-estate/seed/maintenance.ts +63 -0
- package/dist/templates/real-estate/seed/rng.ts +36 -0
- package/dist/templates/real-estate/seed/scales.ts +9 -0
- package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
- package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
- package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
- package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
- package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
- package/dist/templates/real-estate/seed/world.ts +92 -0
- package/dist/templates/real-estate/tsconfig.json +16 -0
- package/dist/templates/real-estate/vitest.config.ts +4 -0
- package/dist/templates/scratch/_.gitignore +3 -0
- package/dist/templates/scratch/_bunfig.toml +4 -0
- package/dist/templates/scratch/db/migrate.ts +30 -0
- package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
- package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
- package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
- package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
- package/dist/templates/scratch/node-types.d.ts +1 -0
- package/dist/{template → templates/scratch}/package.json +5 -5
- package/dist/templates/scratch/tsconfig.json +16 -0
- package/package.json +2 -2
- /package/dist/{template → templates/erp}/_.gitignore +0 -0
- /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
- /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
- /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
- /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
- /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
- /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
- /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
- /package/dist/{template → templates/erp}/tsconfig.json +0 -0
- /package/dist/{template → templates/scratch}/.env.example +0 -0
- /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
- /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
- /package/dist/{template → templates/scratch}/config/index.ts +0 -0
- /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
- /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
- /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
- /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
- /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- The auth schema changes this version of Protobase needs, written by `protobase auth migration`.
|
|
2
|
+
-- Applied with the project's other migrations; edit or remove statements the project handles differently.
|
|
3
|
+
|
|
4
|
+
-- Renames the auth store of Protobase 0.6 to snake_case. A store set up with the snake_case names has no "emailVerified"
|
|
5
|
+
-- and is left as it is.
|
|
6
|
+
do $$
|
|
7
|
+
declare
|
|
8
|
+
constraint_row record;
|
|
9
|
+
begin
|
|
10
|
+
if not exists (select from information_schema.columns where table_schema = 'auth' and table_name = 'user' and column_name = 'emailVerified') then
|
|
11
|
+
return;
|
|
12
|
+
end if;
|
|
13
|
+
|
|
14
|
+
alter table "auth"."twoFactor" rename to two_factor;
|
|
15
|
+
alter table "auth"."signInPolicy" rename to sign_in_policy;
|
|
16
|
+
alter table "auth"."staffSignIn" rename to staff_sign_in;
|
|
17
|
+
|
|
18
|
+
alter table "auth"."user" rename column "emailVerified" to email_verified;
|
|
19
|
+
alter table "auth"."user" rename column "createdAt" to created_at;
|
|
20
|
+
alter table "auth"."user" rename column "updatedAt" to updated_at;
|
|
21
|
+
alter table "auth"."user" rename column "banReason" to ban_reason;
|
|
22
|
+
alter table "auth"."user" rename column "banExpires" to ban_expires;
|
|
23
|
+
alter table "auth"."user" rename column "twoFactorEnabled" to two_factor_enabled;
|
|
24
|
+
|
|
25
|
+
alter table "auth".session rename column "expiresAt" to expires_at;
|
|
26
|
+
alter table "auth".session rename column "createdAt" to created_at;
|
|
27
|
+
alter table "auth".session rename column "updatedAt" to updated_at;
|
|
28
|
+
alter table "auth".session rename column "ipAddress" to ip_address;
|
|
29
|
+
alter table "auth".session rename column "userAgent" to user_agent;
|
|
30
|
+
alter table "auth".session rename column "userId" to user_id;
|
|
31
|
+
alter table "auth".session rename column "impersonatedBy" to impersonated_by;
|
|
32
|
+
|
|
33
|
+
alter table "auth".account rename column "accountId" to account_id;
|
|
34
|
+
alter table "auth".account rename column "providerId" to provider_id;
|
|
35
|
+
alter table "auth".account rename column "userId" to user_id;
|
|
36
|
+
alter table "auth".account rename column "accessToken" to access_token;
|
|
37
|
+
alter table "auth".account rename column "refreshToken" to refresh_token;
|
|
38
|
+
alter table "auth".account rename column "idToken" to id_token;
|
|
39
|
+
alter table "auth".account rename column "accessTokenExpiresAt" to access_token_expires_at;
|
|
40
|
+
alter table "auth".account rename column "refreshTokenExpiresAt" to refresh_token_expires_at;
|
|
41
|
+
alter table "auth".account rename column "createdAt" to created_at;
|
|
42
|
+
alter table "auth".account rename column "updatedAt" to updated_at;
|
|
43
|
+
|
|
44
|
+
alter table "auth".verification rename column "expiresAt" to expires_at;
|
|
45
|
+
alter table "auth".verification rename column "createdAt" to created_at;
|
|
46
|
+
alter table "auth".verification rename column "updatedAt" to updated_at;
|
|
47
|
+
|
|
48
|
+
alter table "auth".jwks rename column "publicKey" to public_key;
|
|
49
|
+
alter table "auth".jwks rename column "privateKey" to private_key;
|
|
50
|
+
alter table "auth".jwks rename column "createdAt" to created_at;
|
|
51
|
+
alter table "auth".jwks rename column "expiresAt" to expires_at;
|
|
52
|
+
|
|
53
|
+
alter table "auth".two_factor rename column "backupCodes" to backup_codes;
|
|
54
|
+
alter table "auth".two_factor rename column "userId" to user_id;
|
|
55
|
+
alter table "auth".two_factor rename column "failedVerificationCount" to failed_verification_count;
|
|
56
|
+
alter table "auth".two_factor rename column "lockedUntil" to locked_until;
|
|
57
|
+
|
|
58
|
+
alter table "auth".passkey rename column "publicKey" to public_key;
|
|
59
|
+
alter table "auth".passkey rename column "userId" to user_id;
|
|
60
|
+
alter table "auth".passkey rename column "credentialID" to credential_id;
|
|
61
|
+
alter table "auth".passkey rename column "deviceType" to device_type;
|
|
62
|
+
alter table "auth".passkey rename column "backedUp" to backed_up;
|
|
63
|
+
alter table "auth".passkey rename column "createdAt" to created_at;
|
|
64
|
+
|
|
65
|
+
alter table "auth".sign_in_policy rename column "emailCode" to email_code;
|
|
66
|
+
alter table "auth".sign_in_policy rename column "twoFactor" to two_factor;
|
|
67
|
+
alter table "auth".sign_in_policy rename column "staffAccess" to staff_access;
|
|
68
|
+
alter table "auth".sign_in_policy rename column "platformSignIn" to platform_sign_in;
|
|
69
|
+
alter table "auth".sign_in_policy rename column "createdAt" to created_at;
|
|
70
|
+
alter table "auth".sign_in_policy rename column "createdBy" to created_by;
|
|
71
|
+
|
|
72
|
+
alter table "auth".staff_sign_in rename column "userId" to user_id;
|
|
73
|
+
alter table "auth".staff_sign_in rename column "userEmail" to user_email;
|
|
74
|
+
alter table "auth".staff_sign_in rename column "staffName" to staff_name;
|
|
75
|
+
alter table "auth".staff_sign_in rename column "staffSubject" to staff_subject;
|
|
76
|
+
alter table "auth".staff_sign_in rename column "sessionId" to session_id;
|
|
77
|
+
alter table "auth".staff_sign_in rename column "startedAt" to started_at;
|
|
78
|
+
alter table "auth".staff_sign_in rename column "expiresAt" to expires_at;
|
|
79
|
+
alter table "auth".staff_sign_in rename column "endedAt" to ended_at;
|
|
80
|
+
|
|
81
|
+
-- A primary key's index takes the new name with it.
|
|
82
|
+
alter table "auth".two_factor rename constraint "twoFactor_pkey" to two_factor_pkey;
|
|
83
|
+
alter table "auth".sign_in_policy rename constraint "signInPolicy_pkey" to sign_in_policy_pkey;
|
|
84
|
+
alter table "auth".staff_sign_in rename constraint "staffSignIn_pkey" to staff_sign_in_pkey;
|
|
85
|
+
alter table "auth".session rename constraint "session_userId_fkey" to session_user_id_fkey;
|
|
86
|
+
alter table "auth".account rename constraint "account_userId_fkey" to account_user_id_fkey;
|
|
87
|
+
alter table "auth".two_factor rename constraint "twoFactor_userId_fkey" to two_factor_user_id_fkey;
|
|
88
|
+
alter table "auth".passkey rename constraint "passkey_userId_fkey" to passkey_user_id_fkey;
|
|
89
|
+
|
|
90
|
+
alter index "auth"."session_userId_idx" rename to session_user_id_idx;
|
|
91
|
+
alter index "auth"."account_userId_idx" rename to account_user_id_idx;
|
|
92
|
+
alter index "auth"."twoFactor_secret_idx" rename to two_factor_secret_idx;
|
|
93
|
+
alter index "auth"."twoFactor_userId_idx" rename to two_factor_user_id_idx;
|
|
94
|
+
alter index "auth"."passkey_userId_idx" rename to passkey_user_id_idx;
|
|
95
|
+
alter index "auth"."passkey_credentialID_idx" rename to passkey_credential_id_idx;
|
|
96
|
+
|
|
97
|
+
-- Postgres 18 names each not-null constraint after its table and column, as "user_emailVerified_not_null"; earlier
|
|
98
|
+
-- versions keep no such constraints, so there is nothing to rename there.
|
|
99
|
+
for constraint_row in
|
|
100
|
+
select c.conrelid::regclass as table_name, c.conname as old_name, t.relname || '_' || a.attname || '_not_null' as new_name
|
|
101
|
+
from pg_constraint c
|
|
102
|
+
join pg_class t on t.oid = c.conrelid
|
|
103
|
+
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = c.conkey[1]
|
|
104
|
+
where c.contype = 'n' and t.relnamespace = (select oid from pg_namespace where nspname = 'auth') and c.conname ~ '[A-Z]'
|
|
105
|
+
loop
|
|
106
|
+
execute format('alter table %s rename constraint %I to %I', constraint_row.table_name, constraint_row.old_name, constraint_row.new_name);
|
|
107
|
+
end loop;
|
|
108
|
+
end
|
|
109
|
+
$$;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- The auth schema changes this version of Protobase needs, written by `protobase auth migration`.
|
|
2
|
+
-- Applied with the project's other migrations; edit or remove statements the project handles differently.
|
|
3
|
+
|
|
4
|
+
alter table "auth"."user" add column if not exists "last_role" text;
|
|
5
|
+
|
|
6
|
+
alter table "auth"."session" add column if not exists "active_role" text;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- The auth schema changes this version of Protobase needs, written by `protobase migration`.
|
|
2
|
+
-- Applied with the project's other migrations; edit or remove statements the project handles differently.
|
|
3
|
+
|
|
4
|
+
alter table "auth"."passkey" add column if not exists "rp_id" text;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- A filter, sort or search reads only columns an index serves.
|
|
2
|
+
create index inspections_condition_idx on maintenance.inspections (organization_id, condition);
|
|
3
|
+
create index lease_tenants_role_idx on leasing.lease_tenants (organization_id, role);
|
|
4
|
+
create index leases_term_idx on leasing.leases (organization_id, term);
|
|
5
|
+
create index rent_charges_outstanding_idx on billing.rent_charges (outstanding);
|
|
6
|
+
create index tenants_first_name_trgm_idx on leasing.tenants using gin (first_name gin_trgm_ops);
|
|
7
|
+
create index tenants_phone_digits_idx on leasing.tenants using gin ((regexp_replace(phone, '[^0-9]', '', 'g')) gin_trgm_ops);
|
|
8
|
+
create index tickets_number_trgm_idx on maintenance.tickets using gin (number gin_trgm_ops);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Everyone who signs in is one of the example's users: their Better Auth id is the next core.users id
|
|
2
|
+
-- (`generateUserId` in auth/auth.ts), which tickets name as their assignee, and their row there, in organization 1
|
|
3
|
+
-- where everyone works, comes with their account. The roles they hold in the app live in the auth schema, so the
|
|
4
|
+
-- example's own role column stays empty for them.
|
|
5
|
+
alter table core.users alter column role drop not null;
|
|
6
|
+
|
|
7
|
+
create function core.add_auth_user() returns trigger language plpgsql as $$
|
|
8
|
+
begin
|
|
9
|
+
insert into core.users (id, organization_id, email, name)
|
|
10
|
+
select new.id::integer, 1, new.email, new.name
|
|
11
|
+
where new.id ~ '^[0-9]+$'
|
|
12
|
+
on conflict do nothing;
|
|
13
|
+
return new;
|
|
14
|
+
end
|
|
15
|
+
$$;
|
|
16
|
+
|
|
17
|
+
create trigger auth_user after insert on auth."user" for each row execute function core.add_auth_user();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "real-estate",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"db:migrate": "bun db/migrate.ts",
|
|
8
|
+
"db:seed:base": "bun seed/base.ts",
|
|
9
|
+
"db:seed": "bun seed/cli.ts",
|
|
10
|
+
"db:reset": "bun db/drop.ts && bun db/migrate.ts && bun seed/cli.ts --scale small",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"protobase": "protobase",
|
|
14
|
+
"build": "protobase build",
|
|
15
|
+
"serve": "protobase build && protobase serve dist",
|
|
16
|
+
"dev": "protobase dev"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@protobase/cli": ">=0.23.14 <1",
|
|
20
|
+
"@protobase/layout": ">=0.23.14 <1",
|
|
21
|
+
"@protobase/schema": ">=0.23.14 <1",
|
|
22
|
+
"@protobase/server": ">=0.23.14 <1",
|
|
23
|
+
"@protobase/ui": ">=0.23.14 <1",
|
|
24
|
+
"better-auth": "^1.7.7",
|
|
25
|
+
"jose": "^6.2.12",
|
|
26
|
+
"kysely": "^0.29.6",
|
|
27
|
+
"pg": "^8.23.1",
|
|
28
|
+
"postgres": "^3.4.9",
|
|
29
|
+
"react": "^19.3.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.20.5",
|
|
33
|
+
"@types/pg": "^8.23.1",
|
|
34
|
+
"@types/react": "^19.3.0",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"vitest": "^5.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineUi, type ActionContext } from '@protobase/ui'
|
|
2
|
+
|
|
3
|
+
/** "Send reminder" on an arrears row: opens a mail to the lease's primary tenant about what is outstanding, with the note it asked for. */
|
|
4
|
+
const remind = async ({ record, client, values }: ActionContext) => {
|
|
5
|
+
const leaseId = String(record!.lease_id)
|
|
6
|
+
const signers = await client.list('lease_tenants', { filter: `lease_id = ${leaseId} AND role = "primary"`, pageSize: 1 })
|
|
7
|
+
const tenantId = signers.items[0]?.tenant_id
|
|
8
|
+
const tenant = tenantId === undefined ? undefined : await client.get('tenants', String(tenantId))
|
|
9
|
+
const lease = await client.get('leases', leaseId)
|
|
10
|
+
const subject = encodeURIComponent(`Rent outstanding on lease ${String(lease.record.number)}`)
|
|
11
|
+
const outstanding = `€${String(record!.balance)} is outstanding, the oldest since ${String(record!.oldest_due_on)}.`
|
|
12
|
+
const body = encodeURIComponent(values?.note ? `${outstanding}\n\n${String(values.note)}` : outstanding)
|
|
13
|
+
window.location.assign(`mailto:${String(tenant?.record.email ?? '')}?subject=${subject}&body=${body}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default defineUi({ actions: { remind } })
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { baseOrganization } from './base-organization'
|
|
3
|
+
|
|
4
|
+
describe('baseOrganization', () => {
|
|
5
|
+
it('is organization 1, named after the app, with its city and KvK number left for the owner', () => {
|
|
6
|
+
expect(baseOrganization('Acme Rentals')).toEqual({ id: 1, name: 'Acme Rentals', slug: 'acme-rentals', city: '', kvk_number: '' })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('makes a slug of any name', () => {
|
|
10
|
+
expect(baseOrganization('Grachtenhof Vastgoed B.V.').slug).toBe('grachtenhof-vastgoed-b-v')
|
|
11
|
+
expect(baseOrganization('!!!').slug).toBe('organization')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('refuses an empty name', () => {
|
|
15
|
+
expect(() => baseOrganization(' ')).toThrow('name')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Lowercase ASCII words joined by dashes; diacritics are dropped, so Müller becomes muller.
|
|
2
|
+
const slugOf = (name: string) =>
|
|
3
|
+
name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'organization'
|
|
4
|
+
|
|
5
|
+
/** Organization 1, the one every user works in (auth/auth.ts), named after the app; the owner fills in the rest. */
|
|
6
|
+
export const baseOrganization = (name: string) => {
|
|
7
|
+
const trimmed = name.trim()
|
|
8
|
+
if (!trimmed) throw new Error('The organization needs a name: pass --name "<the app name>"')
|
|
9
|
+
return { id: 1, name: trimmed, slug: slugOf(trimmed), city: '', kvk_number: '' }
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { connect } from '../db/connection'
|
|
2
|
+
import { baseOrganization } from './base-organization'
|
|
3
|
+
import { amenities } from './data/reference'
|
|
4
|
+
|
|
5
|
+
// `db:seed:base [--name <app name>]`: what a new app starts with, the amenities and organization 1. Rows already
|
|
6
|
+
// there stay as they are, so it can run again and after the sample seed (`db:seed`).
|
|
7
|
+
const flag = process.argv.indexOf('--name')
|
|
8
|
+
const organization = baseOrganization(flag === -1 ? 'Real estate' : (process.argv[flag + 1] ?? ''))
|
|
9
|
+
|
|
10
|
+
const sql = connect({ max: 1 })
|
|
11
|
+
await sql.begin(async (tx) => {
|
|
12
|
+
await tx`insert into portfolio.amenities ${tx(amenities.map(([code, name]) => ({ code, name })))} on conflict do nothing`
|
|
13
|
+
await tx`insert into core.organizations ${tx(organization)} on conflict do nothing`
|
|
14
|
+
// An explicit id leaves the identity sequence behind, so the next organization would take id 1 again.
|
|
15
|
+
await tx`select setval(pg_get_serial_sequence('core.organizations', 'id'), (select max(id) from core.organizations))`
|
|
16
|
+
})
|
|
17
|
+
await sql.end()
|
|
18
|
+
console.log(`seeded the base data of ${organization.name}`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { DAY, addMonths, firstPeriod, monthOf, windowEnd, windowStart } from '../calendar'
|
|
2
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
3
|
+
|
|
4
|
+
// What the ids of everything under a property depend on: world.ts counts units, leases and tenants with these before
|
|
5
|
+
// any row is built, so they take only the organization's seed and an ordinal.
|
|
6
|
+
|
|
7
|
+
const YEAR = 365 * DAY
|
|
8
|
+
|
|
9
|
+
export const propertyShapeAt = (seed: number, ordinal: number) => {
|
|
10
|
+
const rand = rngAt(seed + 1, ordinal)
|
|
11
|
+
const kind = weighted(rand, [['single_family', 45], ['apartment_building', 40], ['mixed_use', 15]] as const)
|
|
12
|
+
const units = kind === 'single_family' ? 1 : kind === 'mixed_use' ? int(rand, 2, 12) : int(rand, 6, 40)
|
|
13
|
+
return { kind, units }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Most of the portfolio was under management before the window; about one property in eight joined during it.
|
|
17
|
+
export const propertyAcquiredAt = (seed: number, ordinal: number) => {
|
|
18
|
+
const rand = rngAt(seed + 2, ordinal)
|
|
19
|
+
if (rand() < 0.88) return Math.floor(windowStart - rand() * 8 * YEAR)
|
|
20
|
+
return Math.floor(windowStart + rand() * (windowEnd - windowStart - 120 * DAY))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Tenancy = { start: number; end: number | null; term: 'fixed' | 'indefinite'; gapAfter: number }
|
|
24
|
+
|
|
25
|
+
// The leases of one unit, oldest first, as months: each starts on a first and ends on a last day of a month, and the
|
|
26
|
+
// next starts after a few empty months at most (the longer gaps are renovations). Only leases that reach into the
|
|
27
|
+
// window are kept. An indefinite lease that runs past the window has no end date yet; a fixed one always has one.
|
|
28
|
+
// `unit` is the organization-wide unit ordinal.
|
|
29
|
+
export const tenanciesOf = (seed: number, unit: number, availableFrom: number) => {
|
|
30
|
+
const rand = rngAt(seed + 6, unit)
|
|
31
|
+
const tenancies: Tenancy[] = []
|
|
32
|
+
let start = availableFrom < windowStart ? addMonths(firstPeriod, -int(rand, 1, 96)) : addMonths(monthOf(availableFrom), int(rand, 1, 3))
|
|
33
|
+
while (start <= windowEnd) {
|
|
34
|
+
const term = rand() < 0.35 ? 'fixed' : 'indefinite'
|
|
35
|
+
const months = term === 'fixed' ? pick(rand, [12, 24]) : weighted(rand, [[int(rand, 12, 36), 35], [int(rand, 37, 96), 45], [int(rand, 97, 240), 20]] as const)
|
|
36
|
+
const next = addMonths(start, months)
|
|
37
|
+
const lastDay = next - DAY
|
|
38
|
+
const end = term === 'fixed' || lastDay <= windowEnd ? lastDay : null
|
|
39
|
+
const gapAfter = weighted(rand, [[0, 55], [1, 25], [2, 12], [int(rand, 3, 5), 8]] as const)
|
|
40
|
+
if (lastDay >= windowStart) tenancies.push({ start, end, term, gapAfter })
|
|
41
|
+
start = addMonths(next, gapAfter)
|
|
42
|
+
}
|
|
43
|
+
return tenancies
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const coSigned = (seed: number, lease: number) => rngAt(seed + 7, lease)() < 0.3
|
|
47
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DAY, addMonths, firstPeriod, lastPeriod, monthOf, windowEnd } from '../calendar'
|
|
2
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
3
|
+
import type { Org } from '../world'
|
|
4
|
+
import { rentAt, type SeededLease } from './tenancy'
|
|
5
|
+
|
|
6
|
+
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
|
7
|
+
|
|
8
|
+
type Payment = { paidOn: number; amountCents: number; method: 'direct_debit' | 'bank_transfer' | 'ideal' }
|
|
9
|
+
|
|
10
|
+
// How one charge of `amount` due on `due` gets paid, by the lease's kind of payer. Payments after the window's end
|
|
11
|
+
// have not happened yet. The parts of a charge never add up to more than the charge.
|
|
12
|
+
const paymentsFor = (payer: SeededLease['payer'], due: number, amount: number, stopped: boolean, rand: () => number): Payment[] => {
|
|
13
|
+
const pay = (days: number, cents: number, method: Payment['method']) => ({ paidOn: due + days * DAY, amountCents: cents, method })
|
|
14
|
+
const transfer = () => pick(rand, ['bank_transfer', 'bank_transfer', 'ideal'] as const)
|
|
15
|
+
if (stopped) return []
|
|
16
|
+
if (payer === 'punctual') return [pay(int(rand, 0, 3), amount, rand() < 0.8 ? 'direct_debit' : 'bank_transfer')]
|
|
17
|
+
if (payer === 'late' || payer === 'defaulter') return [pay(int(rand, 3, 28), amount, transfer())]
|
|
18
|
+
const kind = weighted(rand, [['late', 60], ['split', 32], ['short', 8]] as const)
|
|
19
|
+
if (kind === 'late') return [pay(int(rand, 10, 60), amount, transfer())]
|
|
20
|
+
const part = Math.round(amount * (0.3 + rand() * 0.5))
|
|
21
|
+
if (kind === 'split') return [pay(int(rand, 5, 30), part, transfer()), pay(int(rand, 31, 90), amount - part, transfer())]
|
|
22
|
+
return [pay(int(rand, 10, 60), part, transfer())]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The monthly charges of a lease inside the window, each with the payments made on it by the window's end. A
|
|
27
|
+
* defaulter stops paying for the last two to seven months of the lease (or of the window, while it runs).
|
|
28
|
+
*/
|
|
29
|
+
export const ledgerOf = (org: Org, lease: SeededLease) => {
|
|
30
|
+
const rand = rngAt(org.seed + 12, lease.ordinal)
|
|
31
|
+
const { start, end } = lease.tenancy
|
|
32
|
+
const from = Math.max(firstPeriod, start)
|
|
33
|
+
const to = end === null ? lastPeriod : Math.min(lastPeriod, monthOf(end))
|
|
34
|
+
const stopFrom = lease.payer === 'defaulter' ? addMonths(to, -int(rand, 1, 6)) : Infinity
|
|
35
|
+
const charges = []
|
|
36
|
+
for (let period = from; period <= to; period = addMonths(period, 1)) {
|
|
37
|
+
const date = new Date(period)
|
|
38
|
+
const amountCents = rentAt(lease.startCents, start, period) + lease.serviceCents
|
|
39
|
+
const payments = paymentsFor(lease.payer, period, amountCents, period >= stopFrom, rand).filter((payment) => payment.paidOn <= windowEnd)
|
|
40
|
+
charges.push({
|
|
41
|
+
period,
|
|
42
|
+
dueOn: period,
|
|
43
|
+
description: `Rent ${monthNames[date.getUTCMonth()]} ${date.getUTCFullYear()}`,
|
|
44
|
+
amountCents,
|
|
45
|
+
paidCents: payments.reduce((sum, payment) => sum + payment.amountCents, 0),
|
|
46
|
+
payments,
|
|
47
|
+
// The monthly run bills a week ahead.
|
|
48
|
+
createdAt: Math.min(period - 7 * DAY + 6 * 3_600_000, windowEnd),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return charges
|
|
52
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DAY, windowStart } from '../calendar'
|
|
2
|
+
import { buildingNames, buildingPrefixes, firstNames, ownerCompanyWords, streets, surnames } from '../data/names'
|
|
3
|
+
import { iban } from '../iban'
|
|
4
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
5
|
+
import type { Org } from '../world'
|
|
6
|
+
import { propertyAcquiredAt, propertyShapeAt } from './allocation'
|
|
7
|
+
|
|
8
|
+
const YEAR = 365 * DAY
|
|
9
|
+
const letters = 'ABCDEFGHJKLMNPRTVWXZ'
|
|
10
|
+
|
|
11
|
+
export const ownerAt = (org: Org, ordinal: number) => {
|
|
12
|
+
const rand = rngAt(org.seed + 3, ordinal)
|
|
13
|
+
const kind = weighted(rand, [['private', 55], ['company', 25], ['pension_fund', 8], ['housing_association', 12]] as const)
|
|
14
|
+
const surname = pick(rand, surnames)
|
|
15
|
+
const name =
|
|
16
|
+
kind === 'private' ? `${pick(rand, firstNames)} ${surname}`
|
|
17
|
+
: kind === 'company' ? `${surname.replace(/^(van|de|der|den|el)\s+/i, '').replace(/^\w/, (c) => c.toUpperCase())} ${pick(rand, ownerCompanyWords)} B.V.`
|
|
18
|
+
: kind === 'pension_fund' ? `Stichting Pensioenfonds ${pick(rand, buildingNames)}`
|
|
19
|
+
: `Woonstichting ${pick(rand, buildingNames)}`
|
|
20
|
+
const domain = kind === 'private' ? pick(rand, ['gmail.com', 'outlook.com', 'ziggo.nl', 'kpnmail.nl']) : `${name.toLowerCase().replace(/ b\.v\.$/, '').replace(/[^a-z]+/g, '')}.nl`
|
|
21
|
+
const fee = kind === 'private' ? int(rand, 700, 900) : kind === 'company' ? int(rand, 500, 650) : int(rand, 350, 500)
|
|
22
|
+
return {
|
|
23
|
+
id: org.ownerFirstId + ordinal,
|
|
24
|
+
name,
|
|
25
|
+
kind,
|
|
26
|
+
email: kind === 'private' ? `${name.split(' ')[0]!.toLowerCase()}.${surname.toLowerCase().replace(/[^a-z]+/g, '')}@${domain}` : `beheer@${domain}`,
|
|
27
|
+
phone: rand() < 0.8 ? `+31 ${int(rand, 10, 79)} ${int(rand, 1_000_000, 9_999_999)}` : null,
|
|
28
|
+
iban: iban(rand),
|
|
29
|
+
feePercent: fee / 100,
|
|
30
|
+
createdAt: Math.floor(windowStart - int(rand, 1, 9) * YEAR),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const builtYear = (rand: () => number) =>
|
|
35
|
+
weighted(rand, [[int(rand, 1890, 1940), 25], [int(rand, 1946, 1979), 30], [int(rand, 1980, 2005), 30], [int(rand, 2006, 2024), 15]] as const)
|
|
36
|
+
|
|
37
|
+
export const propertyAt = (org: Org, ordinal: number) => {
|
|
38
|
+
const { kind, units } = propertyShapeAt(org.seed, ordinal)
|
|
39
|
+
const rand = rngAt(org.seed + 4, ordinal)
|
|
40
|
+
const [city, lat, lng, postalFrom, postalTo, rentPerM2] = weighted(rand, org.cities.map((entry, index) => [entry, index === 0 ? 40 : 12] as const))
|
|
41
|
+
const street = pick(rand, streets)
|
|
42
|
+
const number = int(rand, 1, 240)
|
|
43
|
+
const houseNumber = kind === 'single_family' && rand() < 0.1 ? `${number}${pick(rand, ['a', 'b'])}` : String(number)
|
|
44
|
+
const building = `${pick(rand, buildingPrefixes)} ${pick(rand, buildingNames)}`
|
|
45
|
+
return {
|
|
46
|
+
id: org.propertyFirstId + ordinal,
|
|
47
|
+
ownerId: org.ownerFirstId + Math.floor(rand() ** 1.7 * org.ownerCount),
|
|
48
|
+
kind,
|
|
49
|
+
units,
|
|
50
|
+
name: kind === 'single_family' ? `${street} ${houseNumber}` : building,
|
|
51
|
+
shortName: kind === 'single_family' ? `${street} ${houseNumber}` : building.split(' ').slice(1).join(' '),
|
|
52
|
+
street,
|
|
53
|
+
houseNumber,
|
|
54
|
+
postalCode: `${int(rand, postalFrom, postalTo)} ${pick(rand, [...letters])}${pick(rand, [...letters])}`,
|
|
55
|
+
city,
|
|
56
|
+
latitude: (lat + (rand() - 0.5) * 0.06).toFixed(6),
|
|
57
|
+
longitude: (lng + (rand() - 0.5) * 0.09).toFixed(6),
|
|
58
|
+
rentPerM2,
|
|
59
|
+
builtYear: builtYear(rand),
|
|
60
|
+
createdAt: propertyAcquiredAt(org.seed, ordinal),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SeededProperty = ReturnType<typeof propertyAt>
|
|
65
|
+
|
|
66
|
+
const energyLabels = ['A++++', 'A+++', 'A++', 'A+', 'A', 'B', 'C', 'D', 'E', 'F', 'G'] as const
|
|
67
|
+
|
|
68
|
+
// Newer buildings and renovated older ones carry better labels.
|
|
69
|
+
const energyLabel = (builtYear: number, rand: () => number) => {
|
|
70
|
+
const base = builtYear >= 2015 ? 2 : builtYear >= 2000 ? 4 : builtYear >= 1980 ? 6 : 7
|
|
71
|
+
return energyLabels[Math.max(0, Math.min(energyLabels.length - 1, base + int(rand, -2, 2)))]!
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The unit's slot inside its property; `unit` is the organization-wide unit ordinal.
|
|
75
|
+
export const unitAt = (org: Org, property: SeededProperty, unit: number) => {
|
|
76
|
+
const slot = unit - org.unitStarts[property.id - org.propertyFirstId]!
|
|
77
|
+
const rand = rngAt(org.seed + 5, unit)
|
|
78
|
+
const perFloor = property.kind === 'mixed_use' ? 3 : 4
|
|
79
|
+
// Mixed-use blocks have shops on the ground floor, so their homes start on the first.
|
|
80
|
+
const floor = property.kind === 'single_family' ? 0 : Math.floor(slot / perFloor) + (property.kind === 'mixed_use' ? 1 : 0)
|
|
81
|
+
const area = property.kind === 'single_family' ? int(rand, 85, 165) : int(rand, 38, 115)
|
|
82
|
+
const bedrooms = area < 45 ? int(rand, 0, 1) : area < 70 ? 2 : area < 100 ? 3 : 4
|
|
83
|
+
const marketFactor = 0.9 + rand() * 0.25
|
|
84
|
+
return {
|
|
85
|
+
id: org.unitFirstId + unit,
|
|
86
|
+
label: property.kind === 'single_family' ? property.shortName : `${property.shortName} ${floor}.${String((slot % perFloor) + 1).padStart(2, '0')}`,
|
|
87
|
+
floor,
|
|
88
|
+
bedrooms,
|
|
89
|
+
area,
|
|
90
|
+
energyLabel: energyLabel(property.builtYear, rand),
|
|
91
|
+
// 2026 market rent, in whole euros.
|
|
92
|
+
baseRentCents: Math.round(area * property.rentPerM2 * marketFactor * (property.kind === 'single_family' ? 0.92 : 1)) * 100,
|
|
93
|
+
topFloor: property.kind !== 'single_family' && slot >= property.units - perFloor,
|
|
94
|
+
createdAt: property.createdAt,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SeededUnit = ReturnType<typeof unitAt>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DAY, addMonths, lastPeriod, monthOf, windowEnd } from '../calendar'
|
|
2
|
+
import { firstNames, surnames } from '../data/names'
|
|
3
|
+
import { slugify } from '../format'
|
|
4
|
+
import { iban } from '../iban'
|
|
5
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
6
|
+
import type { Org } from '../world'
|
|
7
|
+
import type { Tenancy } from './allocation'
|
|
8
|
+
import type { SeededProperty, SeededUnit } from './property'
|
|
9
|
+
|
|
10
|
+
const YEAR = 365 * DAY
|
|
11
|
+
|
|
12
|
+
/** Let when a lease covers the window's last day, else vacant, or under renovation in a long gap. */
|
|
13
|
+
export const unitStatus = (tenancies: Tenancy[]) => {
|
|
14
|
+
const today = Math.floor(windowEnd / DAY) * DAY
|
|
15
|
+
if (tenancies.some((tenancy) => tenancy.start <= today && (tenancy.end === null || tenancy.end >= today))) return 'let'
|
|
16
|
+
const before = tenancies.filter((tenancy) => tenancy.start <= today).at(-1)
|
|
17
|
+
return before && before.gapAfter >= 3 ? 'renovation' : 'vacant'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The yearly rent increase on 1 July, in the range Dutch free-sector leases allowed.
|
|
21
|
+
const increases: Record<number, number> = { 2016: 0.025, 2017: 0.025, 2018: 0.03, 2019: 0.03, 2020: 0.026, 2021: 0, 2022: 0.033, 2023: 0.055, 2024: 0.055, 2025: 0.041, 2026: 0.044 }
|
|
22
|
+
|
|
23
|
+
/** The rent in cents a lease starting at `startCents` charges for `period` (a month), after every July increase since. */
|
|
24
|
+
export const rentAt = (startCents: number, start: number, period: number) => {
|
|
25
|
+
let rent = startCents
|
|
26
|
+
for (let year = new Date(start).getUTCFullYear(); year <= new Date(period).getUTCFullYear(); year++) {
|
|
27
|
+
const july = Date.UTC(year, 6, 1)
|
|
28
|
+
if (july > start && july <= period) rent = Math.round(rent * (1 + (increases[year] ?? 0.025)))
|
|
29
|
+
}
|
|
30
|
+
return rent
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `lease` is the organization-wide lease ordinal, `index` the lease's position among its unit's leases.
|
|
34
|
+
export const leaseAt = (org: Org, property: SeededProperty, unit: SeededUnit, unitOrdinal: number, index: number, tenancy: Tenancy) => {
|
|
35
|
+
const lease = org.leaseStarts[unitOrdinal]! + index
|
|
36
|
+
const rand = rngAt(org.seed + 8, lease)
|
|
37
|
+
// New leases follow the market: the 2026 rent, about 3.5% less for every year before.
|
|
38
|
+
const startCents = Math.round((unit.baseRentCents * 0.965 ** ((lastPeriod - tenancy.start) / YEAR)) / 100) * 100
|
|
39
|
+
const lastMonth = tenancy.end === null ? lastPeriod : Math.min(lastPeriod, monthOf(tenancy.end))
|
|
40
|
+
const serviceCents = property.kind === 'single_family' ? 0 : int(rand, 9, 24) * 500
|
|
41
|
+
const signedAt = tenancy.start - int(rand, 14, 50) * DAY - int(rand, 9, 17) * 3_600_000
|
|
42
|
+
const notice = tenancy.term === 'indefinite' && tenancy.end !== null ? addMonths(monthOf(tenancy.end), -int(rand, 1, 3)) + int(rand, 0, 20) * DAY : null
|
|
43
|
+
return {
|
|
44
|
+
id: org.leaseFirstId + lease,
|
|
45
|
+
ordinal: lease,
|
|
46
|
+
number: `HC-${new Date(tenancy.start).getUTCFullYear()}-${String(lease + 1).padStart(6, '0')}`,
|
|
47
|
+
unitId: unit.id,
|
|
48
|
+
tenancy,
|
|
49
|
+
startCents,
|
|
50
|
+
rentCents: rentAt(startCents, tenancy.start, lastMonth),
|
|
51
|
+
serviceCents,
|
|
52
|
+
notice,
|
|
53
|
+
// Late payers are few: the arrears view should have something to show, not everything.
|
|
54
|
+
payer: weighted(rand, [['punctual', 74], ['late', 17], ['chronic', 6], ['defaulter', 3]] as const),
|
|
55
|
+
createdAt: signedAt,
|
|
56
|
+
updatedAt: notice ?? signedAt,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SeededLease = ReturnType<typeof leaseAt>
|
|
61
|
+
|
|
62
|
+
const providers = ['gmail.com', 'outlook.com', 'hotmail.com', 'ziggo.nl', 'kpnmail.nl', 'icloud.com', 'live.nl']
|
|
63
|
+
|
|
64
|
+
// The tenants of a lease: the primary tenant first, then a co-signer when there is one.
|
|
65
|
+
export const tenantsOf = (org: Org, lease: SeededLease) => {
|
|
66
|
+
const first = org.tenantStarts[lease.ordinal]!
|
|
67
|
+
const count = org.tenantStarts[lease.ordinal + 1]! - first
|
|
68
|
+
return Array.from({ length: count }, (_, slot) => {
|
|
69
|
+
const rand = rngAt(org.seed + 9, first + slot)
|
|
70
|
+
const firstName = pick(rand, firstNames)
|
|
71
|
+
const lastName = pick(rand, surnames)
|
|
72
|
+
const age = weighted(rand, [[int(rand, 20, 29), 40], [int(rand, 30, 44), 35], [int(rand, 45, 64), 18], [int(rand, 65, 80), 7]] as const)
|
|
73
|
+
return {
|
|
74
|
+
id: org.tenantFirstId + first + slot,
|
|
75
|
+
role: slot === 0 ? 'primary' : 'co_signer',
|
|
76
|
+
firstName,
|
|
77
|
+
lastName,
|
|
78
|
+
email: `${slugify(firstName)}.${slugify(lastName)}${rand() < 0.4 ? int(rand, 1, 99) : ''}@${pick(rand, providers)}`,
|
|
79
|
+
phone: rand() < 0.9 ? `+31 6 ${int(rand, 10_000_000, 99_999_999)}` : null,
|
|
80
|
+
dateOfBirth: lease.tenancy.start - age * YEAR - int(rand, 0, 364) * DAY,
|
|
81
|
+
iban: iban(rand),
|
|
82
|
+
// Landlords ask for an income of about four times the rent; a co-signer adds to it.
|
|
83
|
+
incomeCents: Math.round((lease.startCents * (slot === 0 ? 3 + rand() * 2.5 : 1 + rand() * 2)) / 100) * 100,
|
|
84
|
+
createdAt: lease.createdAt,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The deposit of a lease: two months of its first rent, returned (sometimes in part) after it ends. */
|
|
90
|
+
export const depositFor = (org: Org, lease: SeededLease) => {
|
|
91
|
+
const rand = rngAt(org.seed + 11, lease.ordinal)
|
|
92
|
+
const amountCents = lease.startCents * 2
|
|
93
|
+
const receivedOn = lease.createdAt + int(rand, 1, 7) * DAY
|
|
94
|
+
const returnedOn = lease.tenancy.end === null ? null : lease.tenancy.end + int(rand, 14, 42) * DAY
|
|
95
|
+
if (returnedOn === null || returnedOn > windowEnd) return { amountCents, receivedOn, status: 'held', returnedCents: 0, returnedOn: null }
|
|
96
|
+
const status = weighted(rand, [['returned', 85], ['partially_returned', 12], ['withheld', 3]] as const)
|
|
97
|
+
const returnedCents = status === 'returned' ? amountCents : status === 'withheld' ? 0 : Math.round((amountCents * (0.3 + rand() * 0.6)) / 100) * 100
|
|
98
|
+
return { amountCents, receivedOn, status, returnedCents, returnedOn }
|
|
99
|
+
}
|