Compare commits
868 Commits
docs/dedup
...
39c19b2340
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c19b2340 | |||
| d1f6d6a427 | |||
| 3b227fe9b2 | |||
| 95724c8e3a | |||
| 93c6554c95 | |||
| 72028a7f32 | |||
| d485695357 | |||
| 23a5811342 | |||
| 102ee493f8 | |||
| c70eb1f945 | |||
| 42baaf7bfc | |||
| 319fd7fd1a | |||
| 2315b58764 | |||
| 15a139e86f | |||
| 04ddd59662 | |||
| 2a4dadd5a7 | |||
| 44b004fa8f | |||
| 5ea0c75fff | |||
| 0416dc8d39 | |||
| 990b566eff | |||
| f699533224 | |||
| 79b6ab2ae0 | |||
| cd82958307 | |||
| 478aba1866 | |||
| 8c4c9b967e | |||
| e7fdf75a6c | |||
| 7b74e2314b | |||
| fd69a75980 | |||
| cc5c053a79 | |||
| 64c73a5d77 | |||
| ebe5fe6ed8 | |||
| aedbcfd58d | |||
| 70bf26aea1 | |||
| 4084029962 | |||
| 37ffb2c3b4 | |||
| 49f5c3165b | |||
| 0ed4323826 | |||
| 25988dbfad | |||
| 9305c030de | |||
| 65ed90b603 | |||
| 29fb882478 | |||
| 808e80744b | |||
| 77829485a7 | |||
| 1882bcb2e4 | |||
| a335dbc117 | |||
| 4489ad2431 | |||
| b51d6d3030 | |||
| 865ae5c072 | |||
| 7a7fd76081 | |||
| f4fb7aae84 | |||
| 3c9310f81c | |||
| 7aa639f195 | |||
| 30f6723fef | |||
| 3337a20091 | |||
| 366b0d79fd | |||
| 0ee3cd6073 | |||
| 91d8ee226b | |||
| 24e88ae32e | |||
| 7cf364e03a | |||
| 58203ca8ea | |||
| 8b7099c4c1 | |||
| 68da165b37 | |||
| 10b3b68851 | |||
| 3d9084c94b | |||
| 93e96da43b | |||
| 244fb14ce5 | |||
| 41c64dc126 | |||
| 0f7da79a64 | |||
| b690fb8d56 | |||
| 75fdb9fab4 | |||
| b97f6e945c | |||
| c7325010e6 | |||
| 3cf12b3015 | |||
| 372b585bf9 | |||
| a343eaa257 | |||
| 8be7a6e29d | |||
| d98aa5cc8a | |||
| a7c11f2c51 | |||
| 3e47793ebe | |||
| 14ab8a8161 | |||
| 6c040a617b | |||
| 7dba1a47bb | |||
| 31ba72f344 | |||
| 681b94a8ef | |||
| 172af02f81 | |||
| cb8292464c | |||
| 3bdf59e917 | |||
| 909dd44605 | |||
| 6caf41651f | |||
| 2592e28578 | |||
| fe5f98db23 | |||
| 210748076f | |||
| b6c27b506d | |||
| b00cc24565 | |||
| 8e81670b11 | |||
| cae5d39607 | |||
| 2f1eba3e57 | |||
| e9509dc45c | |||
| 353a31323e | |||
| c8869338e8 | |||
| 400ff993d2 | |||
| c549622af4 | |||
| da391b1830 | |||
| 8998f68c0f | |||
| d32e557e56 | |||
| 911b51a669 | |||
| c4450dd852 | |||
| 866b910ae9 | |||
| 3f9c4589e0 | |||
| 2072f6cac0 | |||
| dd25ccfb53 | |||
| e9ef5831aa | |||
| db14056018 | |||
| e4daa482de | |||
| 3a48150d13 | |||
| bfb29ab619 | |||
| 20549fb22e | |||
| ccc775dc66 | |||
| 4f350d1fbd | |||
| 1e31ed66f1 | |||
| 7476eabec6 | |||
| 35bd8c45d8 | |||
| 3a1c16ae71 | |||
| cd6b19e173 | |||
| 7bdfc340ae | |||
| 9138932d1b | |||
| dd6e8ee968 | |||
| 65b92cace1 | |||
| 13834afa46 | |||
| 81e7aa284e | |||
| 5d43953957 | |||
| d3ec9fdb4e | |||
| c7dbe0bb10 | |||
| 777b711548 | |||
| 14ae41d0fa | |||
| 41737fa950 | |||
| 70d1e7e9b2 | |||
| 5bd0e1ad9a | |||
| 221ae5784e | |||
| 43719b49e9 | |||
| 54c5d0ff1e | |||
| e4fb425d05 | |||
| ee4d5c8610 | |||
| 355f242b8f | |||
| 9ae7940a04 | |||
| c24f9e5508 | |||
| 2f1e1b5f3f | |||
| d0639421bd | |||
| c5affc9b45 | |||
| cb91f78cbc | |||
| fcab7745aa | |||
| c1daed1991 | |||
| 6d665d0113 | |||
| 6af75eda01 | |||
| 589be0bfed | |||
| ef379013e6 | |||
| adf4e2ba78 | |||
| 52493801e0 | |||
| f6cb733424 | |||
| 91be0f9136 | |||
| be261f3f90 | |||
| 6aaccb6d33 | |||
| aa1f5d2835 | |||
| c14f80a4f7 | |||
| 0ed03fcd7f | |||
| a7cbee09ee | |||
| a147cbcd93 | |||
| 0ddaf462c7 | |||
| 65ff5961f2 | |||
| 03a7521729 | |||
| 989cc4d72b | |||
| 94c24a123a | |||
| 431375d794 | |||
| 991e2223c7 | |||
| a0a4a5d487 | |||
| 7ecf4ee813 | |||
| 670ca16a05 | |||
| e33313bd64 | |||
| a555798cfe | |||
| d879188322 | |||
| 5a9b5f687f | |||
| 1cdc2fdc6d | |||
| 47c2ba9a99 | |||
| 3b199c245c | |||
| e91055f784 | |||
| 0c6e7b72af | |||
| f0dbefcac2 | |||
| 292a8b5e4a | |||
| 3999d4bbea | |||
| ca172fa2b8 | |||
| d912f02b97 | |||
| 235e0645cb | |||
| 7881da675b | |||
| 5320398501 | |||
| 8e9efe5ae8 | |||
| 1f591ff7ae | |||
| ded16f4a5b | |||
| a263a202d9 | |||
| 363ef0b882 | |||
| 96069fad16 | |||
| e52b3a6d38 | |||
| bb7a371d1f | |||
| 3ae86f2854 | |||
| 83f75ef0f5 | |||
| b7533fee3e | |||
| a4e30ea16c | |||
| d97a08bf5f | |||
| ae8867d832 | |||
| 28eb76a9d8 | |||
| ec6f90f335 | |||
| 7d48349a75 | |||
| 72d7803be5 | |||
| 5a2dabea05 | |||
| 05e727f462 | |||
| 1f8bd47a7b | |||
| 8fcbe45d36 | |||
| 9adb80ada4 | |||
| f39f0aa7bc | |||
| 348dc94858 | |||
| b74fc56a3b | |||
| 4d3d7489bf | |||
| 552b966903 | |||
| 610154395a | |||
| 2cb0b99314 | |||
| f99d2cd9ec | |||
| ca51000401 | |||
| 901fc363a5 | |||
| 2bcf544cbc | |||
| c18dbbd61b | |||
| db511063df | |||
| 5f937b4551 | |||
| d8da1f634d | |||
| 535ff69fc4 | |||
| 51ca875665 | |||
| b9d388a362 | |||
| c6dcf49e18 | |||
| a673b6cec2 | |||
| 301375a3c3 | |||
| 7cdfed27fa | |||
| 203f543e60 | |||
| 70c7d84dea | |||
| 52342ee45d | |||
| 6a4f4ea1dd | |||
| 8f42940c52 | |||
| 69444878ab | |||
| 6cdb9af6b2 | |||
| abbaf406ab | |||
| 2d574172ec | |||
| 449b9497ab | |||
| 8c669e2918 | |||
| b4bf9cca3f | |||
| bac253b360 | |||
| 292800b643 | |||
| 1b8dacfa54 | |||
| b3f87563c6 | |||
| ef0dc5abc4 | |||
| f938847ed9 | |||
| eaab14943b | |||
| 503207ef68 | |||
| a6e79231f3 | |||
| df1594d596 | |||
| 9f5786890e | |||
| fb4a09e2ec | |||
| 918c23fc0b | |||
| ee3cbb9b39 | |||
| c9debce442 | |||
| 0f99f054b3 | |||
| 4b5f85cb7d | |||
| 397dbd1490 | |||
| d15f5509ad | |||
| 98211066a5 | |||
| 0d9208a052 | |||
| 3b3ac287e0 | |||
| ff5e71092e | |||
| 58940552be | |||
| 202e0b1bc5 | |||
| 7d33e73eef | |||
| d2804de0d1 | |||
| 84468386d9 | |||
| 3e78c2d4ab | |||
| 608641c23b | |||
| e7e498dedd | |||
| 98fe295675 | |||
| f85948488d | |||
| 025648c40b | |||
| 2d0a49e0d1 | |||
| 27f8db4c67 | |||
| 2c57082d8d | |||
| e469b2b6a6 | |||
| 85bd0d82e1 | |||
| 446342aa69 | |||
| b2ba0b4e0a | |||
| a8607ecc9e | |||
| 3c2826635d | |||
| 2a2673e328 | |||
| 66869c9a90 | |||
| 709ef350ff | |||
| 4182652d49 | |||
| a77b3c670a | |||
| e933e32dbd | |||
| fd2c7d6b12 | |||
| d556bb88f7 | |||
| bded8b21f1 | |||
| 81d4e64f69 | |||
| 465650957b | |||
| b966d8106d | |||
| f86f511e7b | |||
| c44d818144 | |||
| 080e1fa454 | |||
| 233129f91a | |||
| 905852b8a5 | |||
| 6b28459c45 | |||
| b10bf9bf8e | |||
| 1a65e02885 | |||
| 0fe3e984d1 | |||
| e11529ffcc | |||
| 05b57abf05 | |||
| 12e22d9be3 | |||
| bd432fc6c7 | |||
| adebd5f91d | |||
| 4d1fbcd469 | |||
| b1dfec09a0 | |||
| 7bf587de90 | |||
| 33d0426911 | |||
| 3dc4c6ff14 | |||
| ebdd8408bf | |||
| 93399ea27e | |||
| 7370b2cd7d | |||
| 19002f4c21 | |||
| b4e502fedd | |||
| 2496911dc4 | |||
| 72237a0191 | |||
| b2c8ed2ff1 | |||
| bc54ea2c3e | |||
| c8ea9ec0a0 | |||
| ecf49be18c | |||
| 0ea8d94d26 | |||
| f183f58b0c | |||
| b397f6049d | |||
| a8dec0bada | |||
| 689a114aba | |||
| eab30c194a | |||
| c1fcc9d5c4 | |||
| 0df761f4ad | |||
| 153f6ac797 | |||
| a49ee1c347 | |||
| 4233aa3ac3 | |||
| b2588ecdd8 | |||
| bb9b5bb1a3 | |||
| 544b129b00 | |||
| 28c788ff41 | |||
| 7675a26889 | |||
| 4ae34dacda | |||
| 8a8cff4c4c | |||
| 96c6b7c01c | |||
| 6ca94ee3f1 | |||
| d1c9469fa7 | |||
| 4329db7fc3 | |||
| ba1db2afea | |||
| d0a3a054b6 | |||
| 75920a2540 | |||
| 9868c68f8f | |||
| 100beb9974 | |||
| 3aa1275ed7 | |||
| dda554df84 | |||
| 92975e6bf5 | |||
| 699ae52827 | |||
| 4879b17cff | |||
| 0ab96d74a8 | |||
| 3147923d91 | |||
| 8baf239759 | |||
| 7cc80512da | |||
| 4eefe58cab | |||
| f3aae61ad8 | |||
| 9fac84658a | |||
| ba921d3865 | |||
| 63220ad072 | |||
| a52e92ae3e | |||
| 18b6827b77 | |||
| d8f1c0c34e | |||
| e386c8d83f | |||
| e8a852856e | |||
| 411d0764e8 | |||
| ed2424cc68 | |||
| b7e010ff80 | |||
| 0e4a2d7396 | |||
| 90fbb66709 | |||
| 6517e014a6 | |||
| 73184c51e0 | |||
| 81a98c6695 | |||
| 8416c5f3c3 | |||
| ff0667ce52 | |||
| 9455ff9981 | |||
| a65aadc530 | |||
| ce662071f8 | |||
| a7a008c62e | |||
| acf878f997 | |||
| d3960af340 | |||
| 82049eea92 | |||
| a7d0dd95e2 | |||
| bfed1543b7 | |||
| ad74e4a174 | |||
| 50f48a8b6a | |||
| 16ef609e1b | |||
| 0baca41693 | |||
| a7b72801be | |||
| bdc9c019a8 | |||
| 4b9743a594 | |||
| 660553c074 | |||
| 0ab7055cf1 | |||
| 04a594963f | |||
| 3ffee79f3f | |||
| 638000bb58 | |||
| 1bdc856589 | |||
| 979eadae48 | |||
| de8726a9b9 | |||
| 606bf19fb5 | |||
| eaa01d25f9 | |||
| f9980900b1 | |||
| 880c5cbafc | |||
| 63f96254e5 | |||
| 76a57b1d6f | |||
| d597e158fe | |||
| ad312df8a4 | |||
| 1f41f8a8a0 | |||
| 9a5ba87d6c | |||
| 955911302b | |||
| b5ebed9c36 | |||
| c761b4b911 | |||
| c0e5af8b92 | |||
| 1b00c8a7a2 | |||
| 0804944647 | |||
| ab798947d8 | |||
| 0e8feb1073 | |||
| eceb77a6c4 | |||
| b598740b2a | |||
| ddc7b78895 | |||
| b6f55636ab | |||
| a4c49f5e5a | |||
| 631b5d7ed5 | |||
| 7f85128dc2 | |||
| 13fe3841d1 | |||
| 2129fbdf15 | |||
| 03738bfa9a | |||
| e5e2e68e5d | |||
| d68d8e5a79 | |||
| ae3f483cb6 | |||
| c9f0bdc687 | |||
| dec54806cb | |||
| d2b0d42e84 | |||
| 3037d832c6 | |||
| 8e2e2ea113 | |||
| ee6e3f3f3f | |||
| 0412107d86 | |||
| 4c5dc7ec17 | |||
| 3b34b41989 | |||
| 86a6944d1c | |||
| 64d0ae540b | |||
| 2f3200764a | |||
| a23a9862cc | |||
| b0831a6872 | |||
| eee4f06737 | |||
| 48f6fb94a7 | |||
| 40e3db237d | |||
| 5422f11747 | |||
| 286eb51f81 | |||
| ef63e86fde | |||
| e790ff708b | |||
| cf8bbf3018 | |||
| ae68e384ca | |||
| 92759d03e8 | |||
| 8e06d4549d | |||
| f8fcb8d8ad | |||
| c8e6371793 | |||
| 433ab3bf75 | |||
| 4556a03b8b | |||
| 4dd1fa4b24 | |||
| e6103a4473 | |||
| ebede74ca0 | |||
| bd8bb2e032 | |||
| d904122498 | |||
| dd481e0c7d | |||
| 1b441ca826 | |||
| 104226f967 | |||
| fb4b9c9595 | |||
| f286c4ef5f | |||
| a0ffa1baae | |||
| e9d5df647d | |||
| 1082b80542 | |||
| 830ac39900 | |||
| 4ec0004867 | |||
| 9f3e739c76 | |||
| e9251a399a | |||
| 5c5ab49218 | |||
| 4b31f01a04 | |||
| e6cf50fd46 | |||
| 4a50bab389 | |||
| 5bed62dc72 | |||
| 51a60c1b9e | |||
| 1bfed587b5 | |||
| 72f50b681c | |||
| b93fdadb59 | |||
| da7ce16344 | |||
| 07b5756014 | |||
| 7c25d1aef6 | |||
| 20ee2c1dcf | |||
| 43191659e6 | |||
| 7804e9bb17 | |||
| ee2da8f67e | |||
| 72ab7180cf | |||
| 8fdf7a92cf | |||
| 91b5a41e10 | |||
| 502455ac04 | |||
| aad514a3bd | |||
| 3f86baeb0f | |||
| 19622985b5 | |||
| 82fd75081a | |||
| 3c47f6b7f9 | |||
| e13232e2ad | |||
| 4d6a293534 | |||
| 9b4aabe04b | |||
| e01a87ff2e | |||
| 1a2d2dd1e1 | |||
| 020aabcb4e | |||
| 2b1024ff7a | |||
| fdb5beb81a | |||
| e2b5898efc | |||
| 6c159a8cac | |||
| f74448c287 | |||
| 2f9bcf00b1 | |||
| 42927482cd | |||
| 8dc16dcd2e | |||
| 60365dc3de | |||
| 5c8c12ba1f | |||
| 3e4d9d6310 | |||
| 267c2b6d1f | |||
| a0e68eb060 | |||
|
|
05babe57a0 | ||
|
|
1a87f28fd4 | ||
|
|
f3143d7561 | ||
|
|
0f648a924b | ||
|
|
b4fb3b2ca6 | ||
|
|
da7ede71d6 | ||
|
|
0a5f085a9e | ||
|
|
c312cd3685 | ||
|
|
59b9e8f177 | ||
|
|
5fc68a5f34 | ||
|
|
a8c6c071e6 | ||
|
|
94331bd6ec | ||
|
|
588f8bc43c | ||
|
|
c5b41ca4b5 | ||
|
|
9890d065f8 | ||
|
|
d2171ea79b | ||
|
|
4592789712 | ||
|
|
758d8628cf | ||
|
|
44db579988 | ||
|
|
7274baf1e1 | ||
|
|
70105715a7 | ||
|
|
472c12280b | ||
|
|
1ae5d88af4 | ||
|
|
8c02f88cbd | ||
|
|
789656bc70 | ||
|
|
fb02f3d5e1 | ||
|
|
e95316bd8a | ||
|
|
d07f1ed5e0 | ||
|
|
f10334683d | ||
|
|
8690352c56 | ||
|
|
9240cf1808 | ||
|
|
adba73fcca | ||
|
|
c60cbf4014 | ||
|
|
f93de75bb5 | ||
|
|
64f0e0a1b8 | ||
|
|
3f6a8aa3b8 | ||
|
|
c90876abad | ||
|
|
8cdee99310 | ||
|
|
d19b74b935 | ||
|
|
1b78eadd36 | ||
|
|
1fb3aa3aeb | ||
|
|
7bd969b41a | ||
|
|
63c4073e64 | ||
|
|
83239104e0 | ||
|
|
4bab6de8be | ||
|
|
4eea4ceff9 | ||
|
|
7854cbabe4 | ||
|
|
d3a6a9beef | ||
|
|
fc7595faf8 | ||
|
|
6a609ecf94 | ||
|
|
cf430d70c3 | ||
|
|
312779c0c5 | ||
|
|
4723994bdc | ||
|
|
c4a41d5f5b | ||
|
|
687a1f1c2f | ||
|
|
ade4c9e77d | ||
|
|
d4b3a1338f | ||
|
|
cf37d09519 | ||
|
|
180912ba9f | ||
|
|
014bbe1923 | ||
|
|
a3e002852b | ||
|
|
312ebf1a88 | ||
|
|
0b8d08b57e | ||
|
|
86372a857f | ||
|
|
b4776b4c3c | ||
|
|
a0091e4ca6 | ||
|
|
249ffe3e4a | ||
|
|
83693dd993 | ||
|
|
15d4849030 | ||
|
|
e00e812199 | ||
|
|
b1e787e55c | ||
|
|
fb1116f1d4 | ||
|
|
5b70e9b04b | ||
|
|
57cbc9a506 | ||
|
|
6e3d910c76 | ||
|
|
ff92a08620 | ||
|
|
05257723f6 | ||
|
|
3017ce4b3a | ||
|
|
a2588f2c4a | ||
|
|
18119644ae | ||
|
|
61e2fbb2db | ||
|
|
05be89ec6f | ||
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 | ||
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 | ||
|
|
b703684285 | ||
|
|
a792d9a182 | ||
|
|
d7ec2a8507 | ||
|
|
cb83b09b2d | ||
|
|
7574c3b575 | ||
|
|
bb105f5365 | ||
|
|
caafae15dd | ||
|
|
46c7389930 | ||
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
cad55e3565 | ||
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 | ||
|
|
4bcc7f8be6 | ||
|
|
18e5c124b0 | ||
|
|
8b077e1999 | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc | ||
|
|
868b1f40c0 | ||
|
|
dbbd03fd22 | ||
|
|
ba5fb6db5e | ||
|
|
886119cbde | ||
|
|
0d357731ad | ||
|
|
a75d4f5d69 | ||
|
|
0fb7920db5 | ||
|
|
16ad61ce15 | ||
|
|
d080bc52fa | ||
|
|
a653c8e039 | ||
|
|
7e8110b2ff | ||
|
|
9eadaf035e | ||
|
|
bcea28cd71 | ||
|
|
722491a9dd | ||
|
|
6009ccb7de | ||
|
|
71da6e8fdc | ||
|
|
c405124bc3 | ||
|
|
53cbee1d3d | ||
|
|
ac7f1db62c | ||
|
|
5d44f3cfa4 | ||
|
|
d0540dca55 | ||
|
|
0e9c24e222 | ||
|
|
3aba2181dc | ||
|
|
6237ad1567 | ||
|
|
34916d855e | ||
|
|
41ae8a328f | ||
|
|
1ff3160eac | ||
|
|
5698d742d3 | ||
|
|
e6ce265be0 | ||
|
|
19bc2f2a54 | ||
|
|
b0a11f1785 | ||
|
|
3cbf2444fe | ||
|
|
0330be1312 | ||
|
|
210360738d | ||
|
|
4df04e1a58 | ||
|
|
0c3baf04c5 | ||
|
|
79667b24da | ||
|
|
c4fdb29bbe | ||
|
|
38527d71fc | ||
|
|
3fbfba6598 | ||
|
|
e3a835675b | ||
|
|
1b085f81ed | ||
|
|
9f786fbcf3 | ||
|
|
906127a292 | ||
|
|
737b43589b | ||
|
|
fbb1f1f366 | ||
|
|
ba89b61b3f | ||
|
|
4eea19a85b | ||
|
|
47a1a51832 | ||
|
|
9a5479c2c7 | ||
|
|
e06fb9545b | ||
|
|
4c5334d471 | ||
|
|
61e40b5e76 | ||
|
|
7f9d90ad05 | ||
|
|
5d29bfc153 | ||
|
|
43f68ca093 | ||
|
|
d9557edfc5 | ||
|
|
6eb0d3dc92 | ||
|
|
a3305a94f3 | ||
|
|
9dfa04094b | ||
|
|
e7d23b254c | ||
|
|
2cf1bd9754 | ||
|
|
46937bbcb9 | ||
|
|
27cdbcc695 | ||
|
|
31fa3d08ec | ||
|
|
16d98d630e | ||
|
|
f52d21df83 | ||
|
|
2fa70f4582 | ||
|
|
01b201e1a2 | ||
|
|
94f049c8b8 | ||
|
|
df495133b7 | ||
|
|
639025ebf9 | ||
|
|
e77d55ac50 | ||
|
|
f1ed2a5f87 | ||
|
|
4036c16f39 | ||
|
|
5f9bbb97bd | ||
|
|
4911083d0f | ||
|
|
3a7fef59b0 | ||
|
|
c081334020 | ||
|
|
2d1b50745a | ||
|
|
40ae860a88 | ||
|
|
c7ca7c1f96 | ||
|
|
22b019a27e | ||
|
|
a3424b80d5 | ||
|
|
5bcdfefde3 | ||
|
|
22f944fde2 | ||
|
|
cda44e721b | ||
|
|
0406778c44 | ||
|
|
259cd7b8bb | ||
|
|
e42b8fde84 | ||
|
|
f354f4adab | ||
|
|
38cd36a616 | ||
|
|
77b6ef5026 | ||
|
|
978df1c4d7 | ||
|
|
df0b408b7a | ||
|
|
1151768159 | ||
|
|
9e69c13202 | ||
|
|
6212c118e5 | ||
|
|
6795db9aa8 | ||
|
|
d8f0cdd7d2 | ||
|
|
2dc53842c0 | ||
|
|
aa15807063 | ||
|
|
2a3fae4d6a | ||
|
|
da7262f18f | ||
|
|
398d6322f1 | ||
|
|
deafc5ef38 | ||
|
|
9b87b14c99 | ||
|
|
da44e8ecbe | ||
|
|
af2db06244 | ||
|
|
0eff6050ae | ||
|
|
d8ac62f6f4 | ||
|
|
dd138547fb | ||
|
|
1791dd7319 | ||
|
|
0ccc66833d | ||
|
|
4877b97f27 | ||
|
|
f2c57c513e | ||
|
|
999622fd08 | ||
|
|
e8d61c91c4 | ||
|
|
fac8021156 | ||
|
|
ea8181d108 | ||
|
|
65b241805e | ||
|
|
4a859245b7 | ||
|
|
4441f1177f | ||
|
|
c4085265ff | ||
|
|
475b051e29 | ||
|
|
4da8ed3ae4 | ||
|
|
4c67b9dbd4 | ||
|
|
0ed401d083 | ||
|
|
456d399ee2 | ||
|
|
f4ec51002c | ||
|
|
2ff24a7132 | ||
|
|
f8255cedb8 | ||
|
|
13d07e3906 | ||
|
|
7ef7b9bb5f | ||
|
|
7200c31486 | ||
|
|
db74c9394b | ||
|
|
d133d6d656 | ||
|
|
9d7decfc5b | ||
|
|
c685c9fada | ||
|
|
71d7daf1ae | ||
|
|
1fd05a886d | ||
|
|
bcf4c1f797 | ||
|
|
f9cb8003b5 | ||
|
|
3b0421aa81 | ||
|
|
a14dc8143c | ||
|
|
b75834ab7e | ||
|
|
4c171848fc | ||
|
|
a6d6647bb2 | ||
|
|
367fc9800e | ||
|
|
ddcffe9f6f | ||
|
|
3c5267f5e9 | ||
|
|
2111bb8b60 | ||
|
|
64d7b5c765 | ||
|
|
4e448dd06e | ||
|
|
29a7fc8857 | ||
|
|
5d76a8a1cf | ||
|
|
d6743ed52c | ||
|
|
ba86b7a897 | ||
|
|
4f56c2bdfd | ||
|
|
508518b6c8 | ||
|
|
f64a52b995 | ||
|
|
76d2348873 | ||
|
|
a604223c17 | ||
|
|
d4f58abb9c | ||
|
|
727e323288 | ||
|
|
7abbdd4913 | ||
|
|
94f8b76a03 | ||
|
|
a78f653f5a | ||
|
|
aca45fb1b2 | ||
|
|
183ff1ff9e | ||
|
|
90463269ce | ||
|
|
a5036c6358 | ||
|
|
f743169354 | ||
|
|
b053a6388e | ||
|
|
b1133c4e87 | ||
|
|
15a79e7990 | ||
|
|
037f2544e8 | ||
|
|
7c408cf975 | ||
|
|
8a5cd1ef0e | ||
|
|
d0ab4b8102 | ||
|
|
aaf4847fc2 | ||
|
|
feacb8c7ac | ||
|
|
2f2ad4452f | ||
|
|
27d438929b | ||
|
|
899e588a0c | ||
|
|
7a6e95c87a | ||
|
|
077ba5bf6b | ||
|
|
14dac2f3e1 | ||
|
|
117cfae52e | ||
|
|
d43298a74e | ||
|
|
88a87afa77 | ||
|
|
299e893e2b | ||
|
|
51523e6768 | ||
|
|
11969c0d8a | ||
|
|
1c0a16fd59 | ||
|
|
b6996f9a31 | ||
|
|
46bd8aaef1 | ||
|
|
b5d8e1ecb8 | ||
|
|
ed40662b99 | ||
|
|
9d815c4dcc | ||
|
|
b9b3f942a6 |
69
.dockerignore
Normal file
69
.dockerignore
Normal file
@@ -0,0 +1,69 @@
|
||||
# Build context exclusions — keep the image small AND prevent secrets
|
||||
# from accidentally leaking into a layer.
|
||||
# The audit caught that the previous absence of this file shipped a
|
||||
# 7.6 GB build context, with .env files reachable via `COPY . .`.
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Local env / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Node / pnpm
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.pnpm-debug.log
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js build artifacts (regenerated inside the image)
|
||||
.next
|
||||
out
|
||||
|
||||
# Tooling caches
|
||||
.cache
|
||||
.turbo
|
||||
.eslintcache
|
||||
.vercel
|
||||
.swc
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
||||
# Testing / coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
test-results
|
||||
playwright-report
|
||||
tests/e2e/visual/snapshots.spec.ts-snapshots/*.png
|
||||
playwright/.cache
|
||||
|
||||
# Project artefacts that don't belong in a runtime image
|
||||
.claude
|
||||
.husky
|
||||
docs
|
||||
AGENTS.md
|
||||
AUDIT-*.md
|
||||
SECURITY-GUIDELINES.md
|
||||
PROMPTS-*.md
|
||||
README.md
|
||||
*.log
|
||||
*.tgz
|
||||
|
||||
# Generated / scratch
|
||||
.serena
|
||||
.superpowers
|
||||
.remember
|
||||
.audit-cache
|
||||
.specstory
|
||||
58
.env.dev.template
Normal file
58
.env.dev.template
Normal file
@@ -0,0 +1,58 @@
|
||||
# ─── Port Nimara CRM — DEV environment template ──────────────────────────────
|
||||
#
|
||||
# Copy to `.env` for local development. Values match the docker-compose.dev.yml
|
||||
# defaults (Postgres on :5434, Redis on :6379, MinIO on :9000).
|
||||
#
|
||||
# Integration credentials (Documenso, OpenAI, SMTP, S3, etc.) belong in the
|
||||
# admin UI after first login — see /admin/<integration>. The fallbacks at the
|
||||
# bottom are commented out by default to make the admin path obvious.
|
||||
|
||||
# ─── Required (boot-time) ────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL=postgresql://crm:changeme@localhost:5434/port_nimara_crm
|
||||
REDIS_URL=redis://:changeme@localhost:6379
|
||||
|
||||
BETTER_AUTH_SECRET=dev-secret-please-change-32-chars-minimum-12345678
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
CSRF_SECRET=dev-csrf-secret-please-change-32-chars-minimum-12345
|
||||
|
||||
# Generated once for local dev. Production uses a different rotated key.
|
||||
EMAIL_CREDENTIAL_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ─── Dev-only safety net ─────────────────────────────────────────────────────
|
||||
|
||||
# When set, every outbound email is rerouted to this address.
|
||||
# Configure to YOUR personal email so seeded fake-client sends don't escape.
|
||||
# EMAIL_REDIRECT_TO=
|
||||
|
||||
# Skip env validation (used by Docker build only).
|
||||
# SKIP_ENV_VALIDATION=
|
||||
|
||||
# ─── Optional integration env fallbacks (admin UI is canonical) ──────────────
|
||||
# Uncomment + set ONLY if you want to bootstrap a port via env. Otherwise
|
||||
# configure each integration via /admin/<integration> after first login.
|
||||
|
||||
# DOCUMENSO_API_URL=https://documenso.dev.example
|
||||
# DOCUMENSO_API_KEY=
|
||||
# DOCUMENSO_API_VERSION=v2
|
||||
# DOCUMENSO_WEBHOOK_SECRET=
|
||||
|
||||
# SMTP_HOST=smtp.example
|
||||
# SMTP_PORT=587
|
||||
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Local MinIO (set if NOT using the admin UI to configure storage)
|
||||
# MINIO_ENDPOINT=localhost
|
||||
# MINIO_PORT=9000
|
||||
# MINIO_ACCESS_KEY=minioadmin
|
||||
# MINIO_SECRET_KEY=minioadmin
|
||||
# MINIO_BUCKET=crm-files
|
||||
# MINIO_USE_SSL=false
|
||||
# MINIO_AUTO_CREATE_BUCKET=true
|
||||
131
.env.example
131
.env.example
@@ -1,46 +1,115 @@
|
||||
# ─── Port Nimara CRM env template ─────────────────────────────────────────────
|
||||
#
|
||||
# This file documents every env var the CRM understands. Most integration
|
||||
# settings have been moved into the per-port admin UI (see
|
||||
# `docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md`):
|
||||
#
|
||||
# /admin/documenso — Documenso API URL, key, version, webhook secret,
|
||||
# signers, templates
|
||||
# /admin/ai — OpenAI API key + model + master switch
|
||||
# /admin/email — SMTP host/port/user/pass, from-address
|
||||
# /admin/storage — S3/MinIO endpoint, bucket, access key, secret key
|
||||
#
|
||||
# After a fresh deploy:
|
||||
# 1. Set the REQUIRED block below (DB/Redis/auth secrets/encryption key).
|
||||
# 2. Boot the app and run `/setup` to create the first super-admin.
|
||||
# 3. Open `/admin/<integration>` and configure each one. Each field shows
|
||||
# a "Using env fallback" badge if it's still inheriting from env, plus
|
||||
# a "Copy from env" button for one-click migration into the DB.
|
||||
#
|
||||
# The COMMENTED env vars in the OPTIONAL block below still work as a runtime
|
||||
# fallback if you set them — useful for staging / dev to bootstrap quickly,
|
||||
# or for backward compatibility with older deployments. New ports inherit
|
||||
# from these as their initial defaults until the admin UI overrides them.
|
||||
#
|
||||
# ─── REQUIRED (boot-time secrets — must be in env) ────────────────────────────
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://crm:changeme@localhost:5432/port_nimara_crm
|
||||
|
||||
# Redis
|
||||
# Redis (BullMQ + Socket.IO adapter)
|
||||
REDIS_URL=redis://:changeme@localhost:6379
|
||||
|
||||
# Auth
|
||||
# Auth (must be 32+ char random strings; rotate carefully)
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
CSRF_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=crm-files
|
||||
MINIO_USE_SSL=false
|
||||
|
||||
# Documenso
|
||||
DOCUMENSO_API_URL=https://documenso.example.com/api/v1
|
||||
DOCUMENSO_API_KEY=your-documenso-api-key
|
||||
DOCUMENSO_WEBHOOK_SECRET=your-webhook-secret-min-16-chars
|
||||
|
||||
# Email (SMTP)
|
||||
SMTP_HOST=mail.portnimara.com
|
||||
SMTP_PORT=587
|
||||
|
||||
# Encryption (64-char hex string for AES-256)
|
||||
# AES-256 key for credential encryption at rest. 64-char hex string.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# CRITICAL: rotating this orphans every encrypted credential in system_settings
|
||||
# (Documenso API key, SMTP password, OpenAI key, S3 access/secret keys).
|
||||
# Plan a re-keying flow before rotating in production.
|
||||
EMAIL_CREDENTIAL_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# Google OAuth (optional)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# OpenAI (optional)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# App
|
||||
# App URL — used by middleware redirects + outbound email link construction.
|
||||
APP_URL=http://localhost:3000
|
||||
PUBLIC_SITE_URL=https://portnimara.com
|
||||
|
||||
# Inlined into the client JS bundle at build time. Must match APP_URL.
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Process basics
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Next.js public
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# When true, the filesystem storage backend refuses to start. Multi-node
|
||||
# deploys MUST use the s3-compatible backend (per CLAUDE.md).
|
||||
# MULTI_NODE_DEPLOYMENT=false
|
||||
|
||||
|
||||
# ─── OPTIONAL: integration env fallbacks ──────────────────────────────────────
|
||||
# Each of the following is configurable in the admin UI. Uncomment + set ANY
|
||||
# of these to provide a fallback that ports inherit when their admin field is
|
||||
# blank. The admin UI labels each inherited field with a "Using env fallback"
|
||||
# badge and offers a "Copy from env" button for one-click migration into the
|
||||
# port-scoped DB row.
|
||||
|
||||
# ─ Documenso (admin: /admin/documenso) ─
|
||||
# DOCUMENSO_API_URL=https://documenso.example.com # Bare host. Never include /api/v1.
|
||||
# DOCUMENSO_API_KEY=your-documenso-api-key # AES-encrypted once written via admin
|
||||
# DOCUMENSO_API_VERSION=v1 # v1 (1.13.x) or v2 (2.x)
|
||||
# DOCUMENSO_WEBHOOK_SECRET= # Min 16 chars. Generate: openssl rand -hex 16
|
||||
# DOCUMENSO_TEMPLATE_ID_EOI=
|
||||
# DOCUMENSO_CLIENT_RECIPIENT_ID=
|
||||
# DOCUMENSO_DEVELOPER_RECIPIENT_ID=
|
||||
# DOCUMENSO_APPROVAL_RECIPIENT_ID=
|
||||
|
||||
# ─ Email / SMTP (admin: /admin/email) ─
|
||||
# SMTP_HOST=mail.portnimara.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS= # AES-encrypted once written via admin
|
||||
# SMTP_FROM= # e.g. "Port Nimara <noreply@example.com>"
|
||||
|
||||
# Dev/test safety net: when set, every outbound email is rerouted to this
|
||||
# address regardless of recipient. Subject is prefixed with [redirected from <orig>].
|
||||
# CRITICAL: env validation refuses boot if NODE_ENV=production AND this is set.
|
||||
# EMAIL_REDIRECT_TO=
|
||||
|
||||
# ─ Storage / S3 / MinIO (admin: /admin/storage) ─
|
||||
# MINIO_ENDPOINT=localhost
|
||||
# MINIO_PORT=9000
|
||||
# MINIO_ACCESS_KEY= # AES-encrypted once written via admin
|
||||
# MINIO_SECRET_KEY= # AES-encrypted (already)
|
||||
# MINIO_BUCKET=crm-files
|
||||
# MINIO_USE_SSL=false
|
||||
# MINIO_AUTO_CREATE_BUCKET=false # Auto-create bucket at boot
|
||||
|
||||
# ─ OpenAI (admin: /admin/ai) ─
|
||||
# OPENAI_API_KEY= # AES-encrypted once written via admin
|
||||
|
||||
# ─ Public marketing site URL (admin: /admin/general — TODO) ─
|
||||
# PUBLIC_SITE_URL=https://portnimara.com
|
||||
|
||||
# ─ Webhook intake from marketing site (deployment-shared, env-only) ─
|
||||
# Shared secret with the marketing website's CRM_INTAKE_SECRET. Min 16 chars.
|
||||
# WEBSITE_INTAKE_SECRET=
|
||||
|
||||
# ─ Sentry (optional — when unset the SDK is a no-op) ─
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# SENTRY_ENVIRONMENT=
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# ─ Google OAuth (not currently used) ─
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
|
||||
58
.env.prod.template
Normal file
58
.env.prod.template
Normal file
@@ -0,0 +1,58 @@
|
||||
# ─── Port Nimara CRM — PROD environment template ─────────────────────────────
|
||||
#
|
||||
# Production env contains ONLY the boot-time minimum: DB connection, auth
|
||||
# secrets, encryption key, app URL, log level. Every integration credential
|
||||
# (Documenso, OpenAI, SMTP, S3) is configured per-port in the admin UI after
|
||||
# the first super-admin completes /setup. This keeps secrets out of the
|
||||
# infrastructure layer (k8s ConfigMap, .env files, deploy logs).
|
||||
#
|
||||
# Generate fresh secrets:
|
||||
# openssl rand -hex 32 # for BETTER_AUTH_SECRET, CSRF_SECRET
|
||||
# openssl rand -hex 32 # for EMAIL_CREDENTIAL_KEY (must be 64 hex chars)
|
||||
|
||||
# ─── Required ────────────────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL=postgresql://USER:PASS@HOST:5432/port_nimara_crm
|
||||
REDIS_URL=redis://:PASS@HOST:6379
|
||||
|
||||
BETTER_AUTH_SECRET=GENERATE_OPENSSL_RAND_HEX_32
|
||||
BETTER_AUTH_URL=https://crm.example.com
|
||||
CSRF_SECRET=GENERATE_OPENSSL_RAND_HEX_32
|
||||
|
||||
# CRITICAL: rotating this orphans every encrypted credential in
|
||||
# system_settings. Plan a re-keying flow before rotating.
|
||||
EMAIL_CREDENTIAL_KEY=GENERATE_OPENSSL_RAND_HEX_32_PRODUCES_64_CHARS
|
||||
|
||||
APP_URL=https://crm.example.com
|
||||
NEXT_PUBLIC_APP_URL=https://crm.example.com
|
||||
|
||||
NODE_ENV=production
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ─── Multi-node guard ────────────────────────────────────────────────────────
|
||||
# Set true if running > 1 app instance. Forces the storage backend off
|
||||
# filesystem onto S3-compatible (filesystem mode is single-node only).
|
||||
MULTI_NODE_DEPLOYMENT=true
|
||||
|
||||
# ─── Sentry (highly recommended in prod) ─────────────────────────────────────
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://YOUR_KEY@YOUR_PROJECT.ingest.sentry.io/PROJECT_ID
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# ─── Webhook intake from marketing site (deployment-shared) ──────────────────
|
||||
# Must match the marketing site's CRM_INTAKE_SECRET. Min 16 chars.
|
||||
WEBSITE_INTAKE_SECRET=GENERATE_OPENSSL_RAND_HEX_16
|
||||
|
||||
# ─── DO NOT SET in production ────────────────────────────────────────────────
|
||||
# EMAIL_REDIRECT_TO — Will fail boot validation (silently rewrites every
|
||||
# outbound email recipient).
|
||||
# SKIP_ENV_VALIDATION — Bypasses safety checks. Internal use only.
|
||||
|
||||
# ─── Integration credentials live in /admin/<integration>, NOT here ──────────
|
||||
# Once deployed:
|
||||
# 1. Run `pnpm exec drizzle-kit push` (or your migration script)
|
||||
# 2. Hit https://crm.example.com/setup to create the first super-admin
|
||||
# 3. Log in → /admin/documenso, /admin/email, /admin/storage, /admin/ai
|
||||
# 4. Configure each integration. AES-encrypted at rest.
|
||||
# 5. Run `pnpm tsx scripts/encrypt-plaintext-credentials.ts` once to encrypt
|
||||
# any legacy plaintext rows from older deployments.
|
||||
30
.gitattributes
vendored
Normal file
30
.gitattributes
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Normalize line endings on commit; check out LF on every OS.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary files — never touch line endings.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.eot binary
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.wasm binary
|
||||
|
||||
# Shell scripts must stay LF regardless.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows batch / PowerShell must stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
48
.gitignore
vendored
48
.gitignore
vendored
@@ -17,3 +17,51 @@ playwright-report/
|
||||
nginx/certs/
|
||||
tsconfig.tsbuildinfo
|
||||
.playwright-mcp/
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||
/eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
# Local-only dashboard widget-combo screenshots — regenerated by manual testing
|
||||
/combos/
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||
/website/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
/.audit-screenshots/
|
||||
|
||||
# Migration script output (CSV reports, transcripts)
|
||||
.migration/
|
||||
|
||||
# Tool caches / runtime state
|
||||
/.claude/
|
||||
/.serena/
|
||||
/ruvector.db
|
||||
|
||||
# Filesystem storage backend root (FilesystemBackend default location)
|
||||
/storage/
|
||||
|
||||
# Private credentials + forensic captures — never commit
|
||||
/private/
|
||||
|
||||
# Local berth-PDF + brochure samples used as upload fixtures during dev.
|
||||
/berth_pdf_example/
|
||||
|
||||
# Scratch / audit artefacts
|
||||
tmp/
|
||||
|
||||
# Internal docs + Claude instructions: kept local-only, not in the shared repo
|
||||
docs/
|
||||
/CLAUDE.md
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write", "node scripts/tsc-staged.mjs"],
|
||||
"*.{json,md,css}": ["prettier --write"]
|
||||
}
|
||||
|
||||
@@ -20,16 +20,42 @@
|
||||
|
||||
### Client Domain
|
||||
|
||||
- `clients` — Anchor records for people/entities
|
||||
- `clients` — Anchor records for people/entities. Yacht and company details
|
||||
are no longer stored here — see the Yacht and Company domains.
|
||||
- `client_contacts` — Multi-channel contact entries per client
|
||||
- `client_addresses` — Physical addresses per client (primary + others)
|
||||
- `client_relationships` — Relationships between clients (referrals, broker, family)
|
||||
- `client_notes` — Timestamped notes on clients
|
||||
- `client_tags` — Tags assigned to clients
|
||||
- `client_merge_log` — Audit trail of client merges
|
||||
|
||||
### Yacht Domain
|
||||
|
||||
- `yachts` — First-class yacht records. Polymorphic ownership via
|
||||
`current_owner_type` (`'client' | 'company'`) + `current_owner_id`.
|
||||
- `yacht_ownership_history` — Append-only log of every transfer; partial
|
||||
unique index `idx_yoh_active` enforces a single active owner per yacht.
|
||||
- `yacht_notes`, `yacht_tags` — Notes / tags on yachts.
|
||||
|
||||
### Company Domain
|
||||
|
||||
- `companies` — Legal entities that may own yachts or be billed.
|
||||
- `company_addresses` — Addresses per company.
|
||||
- `company_memberships` — Active client ↔ company links with role
|
||||
(director / shareholder / beneficial_owner / authorised_signatory),
|
||||
start/end dates.
|
||||
|
||||
### Reservation Domain
|
||||
|
||||
- `berth_reservations` — Concrete client + yacht + berth holds with
|
||||
start/end dates and status. Partial unique index `idx_br_active`
|
||||
enforces one active reservation per berth.
|
||||
|
||||
### Interest Domain
|
||||
|
||||
- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns)
|
||||
- `interests` — Per-berth pipeline records. Each row references a
|
||||
`client_id`, `yacht_id` (the yacht in scope for the inquiry), and
|
||||
optional `berth_id`. Milestone dates are inline columns.
|
||||
- `interest_notes` — Timestamped notes on interests
|
||||
- `interest_tags` — Tags assigned to interests
|
||||
|
||||
|
||||
91
CLAUDE.md
91
CLAUDE.md
@@ -1,91 +0,0 @@
|
||||
# Port Nimara CRM
|
||||
|
||||
Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm db:generate # Generate Drizzle migrations
|
||||
pnpm db:push # Push schema to DB
|
||||
pnpm db:studio # Drizzle Studio GUI
|
||||
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Framework:** Next.js 15.1 App Router, `output: 'standalone'`, `experimental.typedRoutes`
|
||||
- **Auth:** better-auth (session cookie: `pn-crm.session_token`)
|
||||
- **Database:** PostgreSQL via `postgres` driver + Drizzle ORM
|
||||
- **Queue:** BullMQ + Redis (ioredis)
|
||||
- **Storage:** MinIO (S3-compatible)
|
||||
- **Realtime:** Socket.IO with Redis adapter
|
||||
- **UI:** Radix UI primitives, shadcn/ui components (`src/components/ui/`), Lucide icons, CVA + tailwind-merge + clsx
|
||||
- **Forms:** react-hook-form + zod resolvers
|
||||
- **Tables:** TanStack Table
|
||||
- **State:** Zustand stores (`src/stores/`), TanStack React Query
|
||||
- **PDF:** pdfme
|
||||
- **Email:** nodemailer + imapflow + mailparser
|
||||
- **AI:** OpenAI SDK (optional)
|
||||
- **Testing:** Vitest (unit), Playwright (e2e)
|
||||
- **Logging:** pino + pino-pretty
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
app/
|
||||
(auth)/ # Login/auth pages
|
||||
(dashboard)/ # Main app - route: /[portSlug]/...
|
||||
(portal)/ # Client portal
|
||||
api/ # API routes
|
||||
components/
|
||||
ui/ # shadcn/ui base components
|
||||
layout/ # Shell, sidebar, header
|
||||
[domain]/ # Domain components (clients, invoices, berths, etc.)
|
||||
shared/ # Cross-domain shared components
|
||||
hooks/ # React hooks (use-auth, use-permissions, use-socket, etc.)
|
||||
lib/
|
||||
api/ # API client utilities
|
||||
auth/ # better-auth config
|
||||
db/
|
||||
schema/ # Drizzle schema (one file per domain)
|
||||
migrations/ # Generated Drizzle migrations
|
||||
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
|
||||
services/ # Business logic services
|
||||
validators/ # Zod schemas for API input validation
|
||||
utils/ # Shared utilities
|
||||
middleware.ts # Auth middleware (cookie check, redirects)
|
||||
providers/ # React context providers
|
||||
stores/ # Zustand stores
|
||||
types/ # Shared TypeScript types
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript:** Strict mode with `noUncheckedIndexedAccess`. No `any` (ESLint error).
|
||||
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
|
||||
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
|
||||
- **Imports:** Use `@/*` path alias (maps to `src/*`).
|
||||
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`.
|
||||
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files.
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
|
||||
|
||||
## Docker
|
||||
|
||||
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
|
||||
- `Dockerfile.dev` - Dev with bind-mounted source
|
||||
- `Dockerfile.worker` - BullMQ worker process
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` / `docker-compose.prod.yml`
|
||||
|
||||
## Architecture docs
|
||||
|
||||
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,16 +1,32 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 1b: Production dependency tree in a flat (hoisted) node_modules.
|
||||
# Hoisted = symlink-free, so a Docker COPY into the runner is faithful
|
||||
# (copying pnpm's default symlinked layout dereferences and breaks
|
||||
# transitive resolution); complete = the custom socket.io server's deps
|
||||
# (engine.io, accepts, ws, ...) all resolve at runtime.
|
||||
FROM node:20-alpine AS prod-deps
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN echo "node-linker=hoisted" > .npmrc && pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Stage 2: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# NODE_ENV=production in the builder makes `next build` and any code
|
||||
# branching on isProd deterministic (build-auditor M9). Without this,
|
||||
# CSP and other prod-only paths would compile under whatever NODE_ENV
|
||||
# the host carried in.
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build
|
||||
@@ -25,6 +41,26 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
|
||||
# The Next standalone node_modules is a MATCHED SET with the turbopack
|
||||
# server chunks — it resolves turbopack's externalized packages (better-auth,
|
||||
# postgres, pino, minio, ...) by their hashed ids, so REPLACING it makes
|
||||
# every route that uses them 500 with "Failed to load external module".
|
||||
# But the custom server (server-custom.js, CJS via esbuild --packages=external)
|
||||
# require()s deps the trace omits or ships ESM-only: socket.io's closure
|
||||
# (accepts/ws/engine.io/cors) and drizzle-orm's CJS entry (index.cjs). So
|
||||
# MERGE the complete hoisted prod tree INTO the standalone node_modules with
|
||||
# rsync --ignore-existing: it ADDS the missing packages/files and SKIPS
|
||||
# everything the trace already provides (and unlike COPY/cp it tolerates the
|
||||
# trace's pnpm symlinks instead of erroring on symlink-vs-dir). The one
|
||||
# thing the standalone server bootstrap would set — globalThis.AsyncLocalStorage
|
||||
# — is handled up-front by src/server-runtime-preamble.ts.
|
||||
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules /opt/prod-node-modules
|
||||
RUN apk add --no-cache --virtual .merge-deps rsync \
|
||||
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
|
||||
&& rm -rf /opt/prod-node-modules \
|
||||
&& apk del .merge-deps
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health || exit 1
|
||||
CMD ["node", "server-custom.js"]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
# Drop root for the dev runtime — node:alpine ships a `node` user (uid
|
||||
# 1000) for exactly this purpose. Audit caught that running as root in
|
||||
# dev is an unnecessary risk when the bind-mounted source lets a
|
||||
# compromised process write anywhere in the repo.
|
||||
USER node
|
||||
WORKDIR /home/node/app
|
||||
COPY --chown=node:node package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "dev"]
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
# Stage 1: Install dependencies (dev deps needed for esbuild)
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Build the worker bundle
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build:worker
|
||||
|
||||
# Stage 3: Production runner (prod deps only)
|
||||
# Stage 3: Production runner (prod deps only).
|
||||
#
|
||||
# Critical ordering: create the worker user FIRST and chown the workdir
|
||||
# BEFORE pnpm install, so node_modules + lazy-cache directories
|
||||
# (tesseract.js, sharp) are owned by the worker user. Without this, the
|
||||
# previous layout had pnpm install run as root → node_modules root-owned
|
||||
# → tesseract.js / sharp wrote to node_modules/.cache and EACCES'd at
|
||||
# first PDF parse in prod (auditor-K §39).
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
WORKDIR /app
|
||||
RUN chown -R worker:nodejs /app
|
||||
USER worker
|
||||
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
# Healthcheck — pings Redis from inside the worker container. Without
|
||||
# this, a worker whose Redis connection has silently dropped (BullMQ
|
||||
# rejects new jobs but the Node process is alive) is invisible to
|
||||
# compose / swarm and jobs queue indefinitely (auditor-K §40).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const Redis=require('ioredis');const r=new Redis(process.env.REDIS_URL,{maxRetriesPerRequest:1,connectTimeout:3000,lazyConnect:true});r.connect().then(()=>r.ping()).then(()=>{r.disconnect();process.exit(0)}).catch(()=>process.exit(1))" || exit 1
|
||||
CMD ["node", "worker.js"]
|
||||
|
||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,12 +1,22 @@
|
||||
# Port Nimara CRM - Project Progress
|
||||
|
||||
**Last updated:** 2026-03-26
|
||||
**Last updated:** 2026-04-22
|
||||
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
|
||||
**Domain:** pn.letsbe.solutions
|
||||
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
|
||||
|
||||
---
|
||||
|
||||
## Since 2026-03-26
|
||||
|
||||
- **Admin surface expanded** — full admin users + roles management, admin ports + system settings management, user settings, expanded audit log, and berth CRUD completions.
|
||||
- **Reminders system** — promoted from "pages only" to full CRUD with background processors.
|
||||
- **Multi-address clients** — new `client_addresses` table with a partial unique index enforcing one primary address per client.
|
||||
- **Inquiry notifications feature (end-to-end)** — public interest form now fires: (a) confirmation email to the inquiring client, (b) in-app notifications to CRM users with `interests.view`, (c) optional email to configured sales recipients. Public schema expanded with first/last name split, address block, and berth mooring lookup. `sendEmail` gained a plain-text fallback. Admin settings UI exposes `inquiry_contact_email` and `inquiry_notification_recipients`. Plan: `docs/superpowers/plans/2026-04-14-inquiry-notifications.md`.
|
||||
- **Build/infra cleanup** — Next.js 15 static-prerender bugs fixed (Suspense boundaries around `useSearchParams` on `/portal/verify` and `/set-password`), `.gitattributes` added to enforce LF in the index across Windows/macOS checkouts, Docker production build fixes, CI trimmed to build+push (deploy job removed).
|
||||
|
||||
---
|
||||
|
||||
## What's Been Built (Layers 0-4 Complete)
|
||||
|
||||
### Layer 0: Foundation (DONE)
|
||||
@@ -80,8 +90,10 @@
|
||||
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
|
||||
- Service: `notifications.service.ts`
|
||||
- Components: `src/components/notifications/`
|
||||
- [x] **Reminders** - Reminder pages
|
||||
- [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
|
||||
- Pages: `/reminders`
|
||||
- API: `/api/v1/reminders/...` (CRUD, my, overdue, upcoming, complete, dismiss, snooze)
|
||||
- Service: `reminders.service.ts`
|
||||
- [x] **Search** - Global search (inline in topbar), saved views
|
||||
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
|
||||
- Service: `search.service.ts`, `saved-views.service.ts`
|
||||
@@ -178,11 +190,12 @@
|
||||
|
||||
### Priority 1: Deployment & Go-Live
|
||||
|
||||
- [ ] Push to Gitea and verify CI/CD pipeline builds
|
||||
- [x] Push to Gitea (origin/main at `9d815c4` as of 2026-04-22)
|
||||
- [ ] Verify CI/CD pipeline builds the latest image and pushes to the Gitea container registry
|
||||
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
|
||||
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
|
||||
- [ ] Configure production `.env` on server
|
||||
- [ ] Run database migrations (`pnpm db:push`)
|
||||
- [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
|
||||
- [ ] Run seed data (`pnpm db:seed`)
|
||||
- [ ] Verify all services start and health check passes
|
||||
|
||||
|
||||
89
assets/README.md
Normal file
89
assets/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# `assets/`
|
||||
|
||||
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
|
||||
in `next.config.ts`). These files are read with `fs.readFile` from
|
||||
`process.cwd()` at runtime, so they are NOT served as public URLs — use
|
||||
`public/` for that.
|
||||
|
||||
## `eoi-template.pdf`
|
||||
|
||||
The source PDF used by the in-app EOI generation pathway
|
||||
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
|
||||
Documenso EOI template uploads, so both pathways produce equivalent
|
||||
documents.
|
||||
|
||||
The PDF must contain AcroForm fields with these exact names (mirroring the
|
||||
Documenso template's `formValues` keys — see
|
||||
`docs/eoi-documenso-field-mapping.md`):
|
||||
|
||||
| Field name | Type | Filled with |
|
||||
| -------------- | -------- | ----------------------------------------------------- |
|
||||
| `Name` | Text | `EoiContext.client.fullName` |
|
||||
| `Email` | Text | `EoiContext.client.primaryEmail` |
|
||||
| `Address` | Text | `street, city, country` |
|
||||
| `Yacht Name` | Text | `EoiContext.yacht.name` |
|
||||
| `Length` | Text | `EoiContext.yacht.lengthFt` |
|
||||
| `Width` | Text | `EoiContext.yacht.widthFt` |
|
||||
| `Draft` | Text | `EoiContext.yacht.draftFt` |
|
||||
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
|
||||
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
|
||||
| `Purchase` | Checkbox | always `true` |
|
||||
|
||||
The fill path **flattens** the AcroForm after writing values, so the
|
||||
recipient can't edit pre-filled values (yacht dimensions, address, berth
|
||||
number) after the fact. Documenso pathway flattens server-side; the
|
||||
in-app pathway brings the artifact to parity.
|
||||
|
||||
### Expected sha256
|
||||
|
||||
The source PDF's sha256 is pinned to guard against silent template swaps
|
||||
(an unreviewed asset swap would change legal output without a code diff):
|
||||
|
||||
```
|
||||
ba495fd88d99ebe4b7f61acbe397fb2f1cd116e1e1f1b217de93106915c7c44b
|
||||
```
|
||||
|
||||
`scripts/check-eoi-template-sha.ts` verifies this at boot of the in-app
|
||||
pathway; the function exposes the expected hash via `EXPECTED_EOI_SHA256`
|
||||
so tests can re-check after a deliberate template revision.
|
||||
|
||||
To intentionally update the template:
|
||||
|
||||
1. Drop the new PDF as `eoi-template.pdf`.
|
||||
2. Run `shasum -a 256 assets/eoi-template.pdf`.
|
||||
3. Update the hash in this README **and** in
|
||||
`src/lib/pdf/fill-eoi-form.ts` (search for `EXPECTED_EOI_SHA256`).
|
||||
|
||||
### Override path
|
||||
|
||||
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
|
||||
point at a different file (e.g. a fixture).
|
||||
|
||||
### How to extract this PDF
|
||||
|
||||
The legacy flow uploads this PDF to Documenso template ID 8. To get the
|
||||
exact bytes:
|
||||
|
||||
1. In Documenso, open the EOI template.
|
||||
2. Download the source PDF.
|
||||
3. Drop it here as `eoi-template.pdf`.
|
||||
|
||||
### Known asset issue: Email field clipped at top
|
||||
|
||||
The current `eoi-template.pdf` has the `Email` AcroForm field box positioned
|
||||
slightly too low — long email addresses render with the top pixel row
|
||||
clipped. **Fix is asset-side, not code-side**: pdf-lib only fills field
|
||||
boxes, it can't move them. To resolve:
|
||||
|
||||
1. Open `eoi-template.pdf` in any PDF form editor (Acrobat, PDFescape,
|
||||
PDF Studio, or Documenso's own template editor).
|
||||
2. Select the `Email` field box; nudge its `y` origin down by ~3 pt (or
|
||||
increase its height by ~3 pt) so the rendered text has visual margin
|
||||
from the top edge.
|
||||
3. Save → re-upload to Documenso (so both pathways stay in sync) →
|
||||
bump the sha256 in this README + `EXPECTED_EOI_SHA256` per the steps
|
||||
above.
|
||||
|
||||
Affects both the in-app pathway (renders via pdf-lib AcroForm fill) and
|
||||
the Documenso pathway (Documenso's own renderer respects the same field
|
||||
geometry).
|
||||
BIN
assets/eoi-template.pdf
Normal file
BIN
assets/eoi-template.pdf
Normal file
Binary file not shown.
Submodule client-portal deleted from e2d31815cf
@@ -14,12 +14,27 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# build-auditor HIGH: bound memory + log rotation so a stuck query or
|
||||
# noisy log doesn't fill the host disk. Postgres respects shared
|
||||
# buffers env via init.sql; the hard limit here is the container
|
||||
# ceiling.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
@@ -28,6 +43,15 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -42,11 +66,28 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
# build-auditor H5: env.PORT is configurable (default 3000), so
|
||||
# template the port into the healthcheck URL. Otherwise overriding
|
||||
# PORT=8080 via .env makes the container healthy-check itself on
|
||||
# the wrong port and enter a restart loop.
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
# Give the SIGTERM handler in src/server.ts time to drain in-flight
|
||||
# HTTP requests, close Socket.io, and disconnect Redis before Docker
|
||||
# SIGKILLs the process. The internal hard timeout is 25s.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -58,7 +99,19 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# Match the app: BullMQ jobs need time to finish or be released back
|
||||
# to the queue when worker.ts handles SIGTERM.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
@@ -40,7 +42,9 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
# Templatize port so `PORT=…` env overrides don't desync the
|
||||
# healthcheck from the actual listener.
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
||||
# Inquiry Notifications System Design
|
||||
|
||||
Migrates the ActivePieces-powered inquiry notification flow into the CRM. When a client registers interest via the Port Nimara website, the system sends a confirmation email to the client and notifies the sales team -- all using the CRM's own database and email infrastructure instead of NocoDB + ActivePieces.
|
||||
|
||||
## Scope
|
||||
|
||||
- Expand the public interest API to accept all website form fields
|
||||
- Add client address storage (multi-address with primary flag)
|
||||
- Send branded confirmation email to the client
|
||||
- Send notification to sales team (CRM users + optional external recipients)
|
||||
- Make notification recipients and contact email configurable by admins
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New table: `client_addresses`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ----------------- | ---------------------------------------------------------------- |
|
||||
| `id` | uuid PK | `crypto.randomUUID()` |
|
||||
| `client_id` | uuid FK → clients | cascade delete |
|
||||
| `port_id` | uuid FK → ports | cascade delete |
|
||||
| `label` | text | e.g., "Home", "Office", "Billing" |
|
||||
| `street_address` | text | |
|
||||
| `city` | text | |
|
||||
| `state_province` | text | |
|
||||
| `postal_code` | text | |
|
||||
| `country` | text | |
|
||||
| `is_primary` | boolean | default `true`, one-primary-per-client enforced in service layer |
|
||||
| `created_at` | timestamp | default `now()` |
|
||||
| `updated_at` | timestamp | default `now()` |
|
||||
|
||||
Schema file: `src/lib/db/schema/clients.ts` (alongside existing client tables).
|
||||
Relations: added to `src/lib/db/schema/relations.ts` (client has many addresses).
|
||||
|
||||
### No changes to existing tables
|
||||
|
||||
- `clients.preferred_contact_method` already exists -- we populate it from the form.
|
||||
- `interests.berth_id` already exists -- we resolve `mooringNumber` to a berth and link it.
|
||||
- `notifications.type` already has `new_registration` -- we fire it.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
### `POST /api/public/interests`
|
||||
|
||||
Expanded request schema:
|
||||
|
||||
```typescript
|
||||
// Required
|
||||
firstName: string; // max 100
|
||||
lastName: string; // max 100
|
||||
email: string; // email format
|
||||
phone: string;
|
||||
|
||||
// Optional
|
||||
preferredContactMethod: 'email' | 'phone' | 'sms';
|
||||
mooringNumber: string; // e.g., "A3" -- resolved against berths.mooring_number
|
||||
companyName: string;
|
||||
yachtName: string;
|
||||
yachtLengthFt: number;
|
||||
yachtWidthFt: number;
|
||||
yachtDraftFt: number;
|
||||
preferredBerthSize: string;
|
||||
notes: string; // max 2000
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
stateProvince: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
fullName: string; // accepted if firstName/lastName not provided
|
||||
```
|
||||
|
||||
Backward compatibility: if `fullName` is provided without `firstName`/`lastName`, it is used as-is for `clients.full_name`. If `firstName`+`lastName` are provided, they are concatenated.
|
||||
|
||||
### Behavior after record creation
|
||||
|
||||
1. Resolve `mooringNumber` against `berths.mooring_number` for the port. Link `interests.berth_id` if found; leave null if not.
|
||||
2. Store `address` in `client_addresses` with `is_primary: true` and `label: 'Primary'`.
|
||||
3. Set `clients.preferred_contact_method` from the form value.
|
||||
4. Queue client confirmation email (see Email Templates below).
|
||||
5. Fire `new_registration` notifications to sales team (see Notification Flow below).
|
||||
6. Return `201 { data: { id, message } }` unchanged.
|
||||
|
||||
Rate limiting remains 5 requests/hour per IP.
|
||||
|
||||
## Email Templates
|
||||
|
||||
Located in `src/lib/email/templates/`. Each exports a function that accepts a typed data object and returns `{ subject: string, html: string, text: string }`.
|
||||
|
||||
### `inquiry-client-confirmation.ts`
|
||||
|
||||
Sent to the client who submitted the form.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `firstName` -- for the greeting
|
||||
- `mooringNumber` -- berth identifier (nullable)
|
||||
- `contactEmail` -- from `inquiry_contact_email` system setting
|
||||
|
||||
**Subject:** "Thank You for Your Interest in Berth {mooringNumber}" or "Thank You for Your Interest in a Port Nimara Berth" if no berth.
|
||||
|
||||
**Body:** Greeting with first name, confirmation their interest is registered, mention they'll be contacted by preferred method, link to the contact email address.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces client confirmation template.
|
||||
|
||||
### `inquiry-sales-notification.ts`
|
||||
|
||||
Sent to CRM users and optional external recipients.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `fullName`
|
||||
- `email`
|
||||
- `phone`
|
||||
- `mooringNumber` (nullable, defaults to "None")
|
||||
- `crmUrl` -- link to the interest detail page in the CRM (built from port slug + interest ID)
|
||||
|
||||
**Subject:** "New Interest - Port Nimara"
|
||||
|
||||
**Body:** Notifies that a new interest has been registered, shows client details and berth selected, links to the CRM.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces admin notification template.
|
||||
|
||||
Both templates include a plain-text fallback.
|
||||
|
||||
## Notification & Delivery Flow
|
||||
|
||||
### Client confirmation email
|
||||
|
||||
1. After record creation, queue a `send-inquiry-confirmation` job on the `email` BullMQ queue.
|
||||
2. Email worker renders the `inquiry-client-confirmation` template with the interest data.
|
||||
3. Sends via system SMTP (`src/lib/email/index.ts`).
|
||||
4. No in-app notification (client is not a CRM user).
|
||||
|
||||
### Sales team notification
|
||||
|
||||
1. Query all users on the port who have `interests` read permission via their role.
|
||||
2. For each user, call `createNotification()` with type `new_registration`.
|
||||
- The existing notification service checks `user_notification_preferences` (in-app / email / both / neither).
|
||||
- Creates in-app notification + Socket.IO push if `in_app: true`.
|
||||
- Queues `send-notification-email` job if `email: true`.
|
||||
3. Fetch `inquiry_notification_recipients` system setting for the port.
|
||||
4. For each external email, queue a `send-inquiry-sales-notification` job on the `email` queue (bypasses notification preferences since these are not CRM users).
|
||||
|
||||
### Independence
|
||||
|
||||
Client confirmation and sales notifications are independent -- a failure in one does not block the other. The `201` response returns immediately after record creation, before any emails are sent.
|
||||
|
||||
## Admin Configuration
|
||||
|
||||
Two new system settings, managed via the existing admin settings UI:
|
||||
|
||||
### `inquiry_contact_email` (string, per-port)
|
||||
|
||||
The reply-to / contact email shown in client confirmation emails.
|
||||
|
||||
- Default: `sales@portnimara.com`
|
||||
- Displayed as a mailto link in the client confirmation email.
|
||||
|
||||
### `inquiry_notification_recipients` (JSON array of strings, per-port)
|
||||
|
||||
Additional external email addresses that receive the sales team notification.
|
||||
|
||||
- Default: `[]` (empty)
|
||||
- Only CRM users with interests permissions are notified by default.
|
||||
- External recipients receive the sales notification email directly.
|
||||
|
||||
### Existing infrastructure (no changes needed)
|
||||
|
||||
- **Which CRM users get notified**: controlled by roles/permissions.
|
||||
- **How each user receives notifications**: `user_notification_preferences` table.
|
||||
- **Admin settings UI**: already supports custom key-value pairs in `system_settings`.
|
||||
|
||||
## Files to Create or Modify
|
||||
|
||||
### New files
|
||||
|
||||
- `src/lib/db/schema/client-addresses.ts` -- (or added to `clients.ts`)
|
||||
- `src/lib/email/templates/inquiry-client-confirmation.ts`
|
||||
- `src/lib/email/templates/inquiry-sales-notification.ts`
|
||||
|
||||
### Modified files
|
||||
|
||||
- `src/lib/db/schema/clients.ts` -- add `clientAddresses` table export
|
||||
- `src/lib/db/schema/index.ts` -- re-export new table
|
||||
- `src/lib/db/schema/relations.ts` -- add client addresses relations
|
||||
- `src/lib/validators/public-interest.ts` (or wherever `publicInterestSchema` lives) -- expand schema
|
||||
- `src/app/api/public/interests/route.ts` -- berth resolution, address storage, notification + email triggers
|
||||
- `src/lib/queue/workers/email.ts` -- handle `send-inquiry-confirmation` and `send-inquiry-sales-notification` jobs
|
||||
- `src/lib/services/interests.service.ts` -- helper to find users with interests permissions on a port
|
||||
- `src/app/(dashboard)/[portSlug]/admin/settings/settings-manager.tsx` -- register the two new setting keys
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing email templates from the admin UI (templates are in code).
|
||||
- Supplemental forms for collecting missing info (separate feature using existing `form_templates` / `form_submissions` infrastructure).
|
||||
- Documenso EOI integration with address merge fields (separate feature).
|
||||
- Changes to the Port Nimara website form itself (website team wires the form to our API).
|
||||
@@ -1,564 +0,0 @@
|
||||
# Client Deduplication and NocoDB Migration Design
|
||||
|
||||
**Status**: Design draft 2026-05-03 — pending approval.
|
||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
### 1.1 Why this exists
|
||||
|
||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
||||
|
||||
- **252 Interests rows** in NocoDB, against an estimated ~190–200 unique humans (~20–25% duplication rate).
|
||||
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
|
||||
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
|
||||
- **No Clients table.** The conflated structure is structural, not accidental.
|
||||
|
||||
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
|
||||
|
||||
### 1.2 Real duplicate patterns observed in the live data
|
||||
|
||||
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
|
||||
|
||||
| Pattern | Example rows | Signature |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
|
||||
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
|
||||
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
|
||||
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
|
||||
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
|
||||
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
|
||||
|
||||
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
|
||||
|
||||
### 1.3 Dirty data inventory
|
||||
|
||||
The migration normalizer must survive these real values from production:
|
||||
|
||||
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
|
||||
|
||||
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
|
||||
|
||||
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
|
||||
|
||||
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
|
||||
|
||||
### 1.4 Existing battle-tested algorithm
|
||||
|
||||
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
|
||||
|
||||
### 1.5 Why the website is no longer the source of new dirty data
|
||||
|
||||
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Three artifacts, layered:
|
||||
|
||||
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
|
||||
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
|
||||
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
|
||||
|
||||
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
|
||||
|
||||
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Normalization library
|
||||
|
||||
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
|
||||
|
||||
### 3.1 `normalizeName(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeName(raw: string): {
|
||||
display: string; // human-readable, kept for UI
|
||||
normalized: string; // for matching
|
||||
surnameToken?: string; // for surname-based blocking
|
||||
};
|
||||
```
|
||||
|
||||
- Trim leading/trailing whitespace
|
||||
- Replace `\r`, `\n`, tabs with single space
|
||||
- Collapse consecutive whitespace to single space
|
||||
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
|
||||
- `display` preserves user's intent (slash-with-company stays intact)
|
||||
- `normalized` is `display.toLowerCase()` for comparison
|
||||
- `surnameToken` is the last non-particle token for blocking
|
||||
|
||||
### 3.2 `normalizeEmail(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeEmail(raw: string): string | null;
|
||||
```
|
||||
|
||||
- Trim + lowercase
|
||||
- Validate via `zod.email()` schema
|
||||
- Returns `null` for empty / invalid (caller decides what to do)
|
||||
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
|
||||
|
||||
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
|
||||
|
||||
```ts
|
||||
export function normalizePhone(
|
||||
raw: string,
|
||||
defaultCountry: string,
|
||||
): {
|
||||
e164: string | null; // canonical, e.g. '+15742740548'
|
||||
country: string | null; // ISO-3166-1 alpha-2
|
||||
display: string | null; // user-facing pretty
|
||||
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
|
||||
} | null;
|
||||
```
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
|
||||
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
|
||||
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
|
||||
4. If starts with `00` → replace with `+`
|
||||
5. If starts with `+` → parse as E.164
|
||||
6. Else if `defaultCountry` provided → parse against that country
|
||||
7. Else return null (caller's problem)
|
||||
|
||||
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
|
||||
|
||||
### 3.4 `resolveCountry(text: string)`
|
||||
|
||||
```ts
|
||||
export function resolveCountry(text: string): {
|
||||
iso: string | null; // ISO-3166-1 alpha-2
|
||||
confidence: 'exact' | 'fuzzy' | 'city' | null;
|
||||
};
|
||||
```
|
||||
|
||||
Reuses `src/lib/i18n/countries.ts`. Pipeline:
|
||||
|
||||
1. Lowercase + strip diacritics
|
||||
2. Exact match against country names (any locale we ship)
|
||||
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
|
||||
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
|
||||
|
||||
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dedup algorithm
|
||||
|
||||
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
|
||||
|
||||
### 4.1 Public API
|
||||
|
||||
```ts
|
||||
export interface MatchCandidate {
|
||||
id: string;
|
||||
fullName: string | null;
|
||||
emails: string[]; // already normalized
|
||||
phonesE164: string[]; // already normalized E.164
|
||||
countryIso: string | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
candidate: MatchCandidate;
|
||||
score: number; // 0–100
|
||||
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export function findClientMatches(
|
||||
input: MatchCandidate,
|
||||
pool: MatchCandidate[],
|
||||
thresholds: DedupThresholds,
|
||||
): MatchResult[];
|
||||
```
|
||||
|
||||
### 4.2 Scoring rules (compound)
|
||||
|
||||
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
|
||||
|
||||
| Rule | Score | Notes |
|
||||
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
|
||||
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
|
||||
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
|
||||
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
|
||||
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
|
||||
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
|
||||
| **Negative**: Same email but different country code on phone | −15 | Suggests spouse / coworker / shared inbox |
|
||||
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | −20 | Two distinct people with the same name |
|
||||
|
||||
### 4.3 Confidence tiers (post-compound)
|
||||
|
||||
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
|
||||
- **score 50–89 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
|
||||
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
|
||||
|
||||
### 4.4 Blocking strategy
|
||||
|
||||
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
|
||||
|
||||
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
|
||||
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
|
||||
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
|
||||
|
||||
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 0–5 candidates per query, regardless of N.
|
||||
|
||||
### 4.5 Performance budget
|
||||
|
||||
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
|
||||
|
||||
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k–10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
|
||||
|
||||
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configurable thresholds (admin settings)
|
||||
|
||||
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
|
||||
|
||||
| Key | Default | Effect |
|
||||
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
|
||||
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
|
||||
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
|
||||
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
|
||||
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
|
||||
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
|
||||
|
||||
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
|
||||
|
||||
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Merge service contract
|
||||
|
||||
### 6.1 Data flow
|
||||
|
||||
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
|
||||
|
||||
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
|
||||
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
|
||||
- `interests.clientId`
|
||||
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
|
||||
- `clientAddresses.clientId` — same conflict handling
|
||||
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
|
||||
- `clientTags.clientId`
|
||||
- `clientYachtMembership.clientId` (or whatever the table is called)
|
||||
- `auditLogs.entityId` — annotate, don't move (audit truth)
|
||||
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
|
||||
4. **Soft-archive loser** — `loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
|
||||
5. **Write `clientMergeLog`** — `{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
|
||||
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
|
||||
|
||||
### 6.2 Schema additions (migration)
|
||||
|
||||
`clients` table gets a new column:
|
||||
|
||||
```ts
|
||||
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
|
||||
```
|
||||
|
||||
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
```
|
||||
|
||||
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
|
||||
|
||||
### 6.3 Undo
|
||||
|
||||
`unmergeClients(mergeLogId, ctx)`:
|
||||
|
||||
1. Within the undo window, look up the snapshot
|
||||
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
|
||||
3. Restore loser's contacts/addresses/notes/tags from snapshot
|
||||
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
|
||||
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
|
||||
|
||||
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
|
||||
|
||||
### 6.4 Concurrency
|
||||
|
||||
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime surfaces
|
||||
|
||||
### 7.1 Layer 1 — At-create suggestion
|
||||
|
||||
In `ClientForm` (and the public `register` form once that hits the new system):
|
||||
|
||||
- Debounced 300ms after email or phone field changes
|
||||
- Calls `findClientMatches` against current port's clients
|
||||
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ This looks like an existing client │
|
||||
│ ML Marcus Laurent │
|
||||
│ marcus@… +33 6 12 34 56 78 │
|
||||
│ 2 interests · last 9d ago │
|
||||
│ [ Use this client ] [ Create new ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
|
||||
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
|
||||
|
||||
### 7.2 Layer 2 — Interest-level same-berth guard
|
||||
|
||||
Cheap one-liner in `createInterest` service:
|
||||
|
||||
- Check `(clientId, berthId)` against existing non-archived interests
|
||||
- If hit, throw `BerthDuplicateError` with the existing interest details
|
||||
- UI catches and prompts: "Update existing or create separate?"
|
||||
|
||||
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
|
||||
|
||||
### 7.3 Layer 3 — Background scoring + review queue
|
||||
|
||||
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
|
||||
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
|
||||
```ts
|
||||
export const clientMergeCandidates = pgTable('client_merge_candidates', {
|
||||
id: text('id').primaryKey()...,
|
||||
portId: text('port_id').notNull()...,
|
||||
clientAId: text('client_a_id').notNull()...,
|
||||
clientBId: text('client_b_id').notNull()...,
|
||||
score: integer('score').notNull(),
|
||||
reasons: jsonb('reasons').notNull(),
|
||||
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
||||
createdAt: timestamp('created_at')...,
|
||||
resolvedAt: timestamp('resolved_at'),
|
||||
resolvedBy: text('resolved_by'),
|
||||
})
|
||||
```
|
||||
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
|
||||
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
|
||||
|
||||
---
|
||||
|
||||
## 8. NocoDB → new system field mapping
|
||||
|
||||
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
|
||||
|
||||
### 8.1 Top-level transform
|
||||
|
||||
```
|
||||
NocoDB Interests row
|
||||
─→ 0–1 client (deduped against existing pool)
|
||||
─→ 0–1 client_address
|
||||
─→ 0–2 client_contacts (email, phone)
|
||||
─→ exactly 1 interest
|
||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
||||
─→ 0–1 document (when documensoID present)
|
||||
```
|
||||
|
||||
### 8.2 Field map
|
||||
|
||||
| NocoDB field | Target | Transform |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Full Name` | `clients.fullName` | `normalizeName().display` |
|
||||
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
|
||||
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
|
||||
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
|
||||
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
|
||||
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
|
||||
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
|
||||
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
|
||||
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
|
||||
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
|
||||
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
|
||||
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
|
||||
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
|
||||
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
|
||||
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
|
||||
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
|
||||
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
|
||||
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
|
||||
| `Time LOI Sent` | `interests.dateContractSent` | parse |
|
||||
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
|
||||
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
|
||||
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
|
||||
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
|
||||
|
||||
### 8.3 Sales-stage mapping (8 → 9)
|
||||
|
||||
| NocoDB | New (PIPELINE_STAGES) |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| General Qualified Interest | `open` |
|
||||
| Specific Qualified Interest | `details_sent` |
|
||||
| EOI and NDA Sent | `eoi_sent` |
|
||||
| Signed EOI and NDA | `eoi_signed` |
|
||||
| Made Reservation | `deposit_10pct` |
|
||||
| Contract Negotiation | `contract_sent` |
|
||||
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
|
||||
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
|
||||
|
||||
### 8.4 Other tables
|
||||
|
||||
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
|
||||
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
|
||||
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
|
||||
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
|
||||
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
|
||||
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration script
|
||||
|
||||
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
|
||||
|
||||
```
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
|
||||
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
|
||||
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
|
||||
Reads the apply log, undoes the writes (only valid within the undo window).
|
||||
```
|
||||
|
||||
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
|
||||
|
||||
### 9.1 Dry-run report format
|
||||
|
||||
`.migration/<timestamp>/report.csv`:
|
||||
|
||||
```csv
|
||||
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
|
||||
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
|
||||
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
|
||||
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
|
||||
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
|
||||
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
|
||||
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
|
||||
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
|
||||
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
|
||||
```
|
||||
|
||||
Plus `.migration/<timestamp>/summary.md`:
|
||||
|
||||
```
|
||||
# Migration Dry-Run — 2026-05-03 14:23 UTC
|
||||
|
||||
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
|
||||
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
|
||||
|
||||
Auto-linked (high confidence, no human action needed):
|
||||
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
|
||||
- John Lynch: rows 716,725 → 1 client + 2 interests
|
||||
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
|
||||
- [12 more]
|
||||
|
||||
Flagged for manual review (medium confidence):
|
||||
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
|
||||
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
|
||||
- [4 more]
|
||||
|
||||
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
|
||||
- Row 239: "Sag Harbor Y" → AI (likely US)
|
||||
- [6 more]
|
||||
|
||||
Phone parsing failed for 3 rows. All flagged, no contact created:
|
||||
- Row 178: empty
|
||||
- Row 641: placeholder "+447000000000"
|
||||
- Row 175: empty
|
||||
|
||||
Run `--apply` to commit these changes.
|
||||
```
|
||||
|
||||
### 9.2 Apply phase
|
||||
|
||||
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
|
||||
|
||||
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
|
||||
|
||||
### 9.3 Idempotency
|
||||
|
||||
The script tracks NocoDB row IDs in a `migration_source_links` table:
|
||||
|
||||
```ts
|
||||
export const migrationSourceLinks = pgTable('migration_source_links', {
|
||||
id: text('id').primaryKey()...,
|
||||
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
|
||||
sourceId: text('source_id').notNull(), // NocoDB row id as string
|
||||
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
|
||||
targetEntityId: text('target_entity_id').notNull(),
|
||||
appliedAt: timestamp('applied_at')...,
|
||||
appliedBy: text('applied_by'),
|
||||
}, (table) => [
|
||||
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
|
||||
]);
|
||||
```
|
||||
|
||||
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
### 10.1 Library-level (vitest unit)
|
||||
|
||||
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
|
||||
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
|
||||
|
||||
### 10.2 Service-level (vitest integration)
|
||||
|
||||
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
|
||||
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
|
||||
|
||||
### 10.3 Migration script (vitest integration with NocoDB mock)
|
||||
|
||||
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
|
||||
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
|
||||
|
||||
### 10.4 E2E (Playwright)
|
||||
|
||||
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
|
||||
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollback plan
|
||||
|
||||
Three layers of safety, ordered by reversibility:
|
||||
|
||||
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
|
||||
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
|
||||
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
|
||||
|
||||
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items
|
||||
|
||||
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
|
||||
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
|
||||
- **Profile photo / face match** — out of scope.
|
||||
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
|
||||
|
||||
---
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
|
||||
|
||||
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
|
||||
|
||||
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~5–7 days.
|
||||
|
||||
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
|
||||
|
||||
Total: ~10–12 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.
|
||||
@@ -1,27 +1,130 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
|
||||
...nextCoreWebVitals,
|
||||
prettier,
|
||||
{
|
||||
// Scope the typescript-eslint rule overrides to TS/TSX files. Without
|
||||
// the `files` filter, eslint flat-config attempts to apply these
|
||||
// rules to every walked file (including root-level JS / mjs / json
|
||||
// configs) and fails because the typescript-eslint plugin only
|
||||
// registers itself for TS/TSX. Surfaced 2026-05-14 when CI's
|
||||
// `pnpm lint` command ran across the whole repo root.
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
// React Compiler safety rules shipped with eslint-config-next@16 /
|
||||
// react-hooks@7. Triage status (2026-05-13 sweep):
|
||||
// purity, set-state-in-render, immutability, refs,
|
||||
// set-state-in-effect — promoted to error after the cleanup
|
||||
// sweep (Wave 3 of the 2026-05-12 audit). All hits migrated to
|
||||
// either useQuery, render-phase derivation, key-based remount,
|
||||
// or a justified eslint-disable for canonical setState-on-
|
||||
// subscription patterns. New regressions block CI.
|
||||
// incompatible-library — informational only ("Compiler
|
||||
// skipped this file because of a non-Compiler-safe import").
|
||||
// No action needed; silenced to keep `pnpm lint` output
|
||||
// actionable.
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/set-state-in-render': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
// Icon-only buttons must carry a label that screen readers can
|
||||
// surface — either an explicit `aria-label`, an `aria-labelledby`,
|
||||
// a `title`, or a visible-but-sr-only text child. Catches the
|
||||
// pattern where a `<button><Trash2 /></button>` ships with no
|
||||
// accessible name. Default Next config enables this at `error`;
|
||||
// we keep it loud so new code doesn't regress.
|
||||
'jsx-a11y/control-has-associated-label': [
|
||||
'warn',
|
||||
{
|
||||
labelAttributes: ['label'],
|
||||
controlComponents: ['Button'],
|
||||
ignoreElements: ['audio', 'canvas', 'embed', 'input', 'textarea', 'tr', 'video'],
|
||||
ignoreRoles: [
|
||||
'grid',
|
||||
'listbox',
|
||||
'menu',
|
||||
'menubar',
|
||||
'radiogroup',
|
||||
'row',
|
||||
'tablist',
|
||||
'toolbar',
|
||||
'tree',
|
||||
'treegrid',
|
||||
],
|
||||
depth: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["client-portal/**"],
|
||||
// User-facing copy in src/components and src/app should never use
|
||||
// em-dashes (—) in JSX text. The user reads em-dashes as a
|
||||
// tell-tale "AI-generated" marker; we prefer periods, commas, or
|
||||
// simple hyphens. Code comments / audit-log strings / templates
|
||||
// outside these directories are exempt.
|
||||
//
|
||||
// Same rule block also nudges new code toward CSS logical properties
|
||||
// (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e) instead of
|
||||
// physical Tailwind utilities. RTL isn't a roadmap requirement today,
|
||||
// but every new ml-/mr-/pl-/pr-/text-left/text-right we accept now
|
||||
// is a class we'd have to migrate later. Existing 1,000+ sites stay
|
||||
// untouched (warn-only). Inline `// eslint-disable-next-line` when
|
||||
// the directional intent is truly physical (e.g. a chevron icon).
|
||||
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
|
||||
rules: {
|
||||
// Both selectors share `warn` severity because the RTL nudge is
|
||||
// grandfathered (1,000+ existing sites use ml-/mr-/etc). The
|
||||
// em-dash sweep cleared every existing instance (2026-05-21), so
|
||||
// `warn` still effectively gates new code — it just doesn't break
|
||||
// CI on grandfathered RTL utilities. Inline
|
||||
// `// eslint-disable-next-line no-restricted-syntax` when the
|
||||
// directional intent is truly physical.
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
selector: "JSXText[value=/\\u2014/]",
|
||||
message:
|
||||
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"JSXAttribute[name.name='className'] > Literal[value=/(?:^|[\\s:])(?:ml-|mr-|pl-|pr-|text-left|text-right|border-l\\b|border-r\\b|rounded-l-|rounded-r-)/]",
|
||||
message:
|
||||
'Prefer CSS logical properties (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e/rounded-s-/rounded-e-) over physical directional Tailwind utilities. Existing code is grandfathered; new code should default to logical so a future RTL pass is bounded.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Tests assert response shape via expect() — narrowing every
|
||||
// `res.json()` to a structural type adds boilerplate without catching
|
||||
// bugs. Allow `any` casts at JSON boundaries in test files. Also
|
||||
// relax unused-vars to warn (destructured-but-unused helpers are
|
||||
// common in setup/teardown patterns).
|
||||
files: ['tests/**/*.ts', 'tests/**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'client-portal/**',
|
||||
'next-env.d.ts',
|
||||
// Agent worktree artifacts — not part of the canonical tree.
|
||||
'.claude/**',
|
||||
// Build output + Next generated types
|
||||
'.next/**',
|
||||
'dist/**',
|
||||
// Other sub-projects with their own toolchains
|
||||
'website/**',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
20
instrumentation.ts
Normal file
20
instrumentation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Next.js instrumentation hook (Next 13.4+ / 15+ / 16+).
|
||||
*
|
||||
* Runs once at server startup. We use it to wire Sentry's server +
|
||||
* edge runtimes. The client init is auto-bundled by withSentryConfig
|
||||
* from `sentry.client.config.ts`.
|
||||
*
|
||||
* The Sentry imports are gated behind the DSN check so the SDK stays
|
||||
* a no-op when unconfigured.
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
151
next.config.ts
151
next.config.ts
@@ -1,7 +1,96 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build`
|
||||
// to get treemaps of the client + server bundles after the build
|
||||
// completes. Pairs with the recharts dynamic-import work the audit
|
||||
// flagged — gives us the tool to verify chart bundles only ship on the
|
||||
// dashboard surface and not on routes that don't render them.
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
/**
|
||||
* Security headers applied to every response. Per audit-pass-#3 finding:
|
||||
* the previous config emitted no CSP, X-Frame-Options, HSTS, or
|
||||
* X-Content-Type-Options — the app was open to clickjacking + MIME
|
||||
* sniffing.
|
||||
*
|
||||
* CSP notes:
|
||||
* - 'unsafe-inline' on style-src is required by Tailwind's runtime
|
||||
* style injection and Radix; revisit when Tailwind v4 ships a
|
||||
* nonce story.
|
||||
* - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for
|
||||
* HMR. Production drops it.
|
||||
* - connect-src allows ws/wss for Socket.IO and https: for outgoing
|
||||
* fetches; tighten in prod via per-port branding URLs once we move
|
||||
* the s3 image references into a known allowlist.
|
||||
* - img-src https: is wide because port branding pulls from
|
||||
* s3.portnimara.com plus per-port image URLs configured at runtime.
|
||||
*/
|
||||
// Dev-only allow-list: react-grab (the in-page click-to-source devtool)
|
||||
// is fetched from unpkg, so script/style/connect must allow it. Strip
|
||||
// these entries in prod via the conditional below.
|
||||
const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
|
||||
// Fallback CSP for paths the proxy doesn't run on (static assets,
|
||||
// API JSON responses where script-src is moot). Production HTML
|
||||
// responses get a stricter per-request nonce-based CSP set in
|
||||
// `src/proxy.ts:applyCsp`; this header just provides a sane default
|
||||
// so a misconfigured static-only route still has a CSP.
|
||||
//
|
||||
// Dev keeps 'unsafe-inline' + 'unsafe-eval' on script-src because
|
||||
// Next's HMR runtime evaluates code dynamically and the nonce
|
||||
// machinery doesn't reach it.
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Content-Security-Policy', value: csp },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
||||
...(isProd
|
||||
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Hide the floating dev indicator (the little circle/N badge in the
|
||||
// corner). Compile errors still surface via the full overlay; this
|
||||
// only removes the idle "everything is fine" indicator that's been
|
||||
// visible in every screenshot from the iPhone testing pass.
|
||||
devIndicators: false,
|
||||
// LAN access from a real iPhone hits the dev server via the Mac's
|
||||
// local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
|
||||
// and blocks cross-origin /_next/* fetches (incl. HMR) unless we
|
||||
// allow-list the origins explicitly. When HMR is blocked the page
|
||||
// never fully hydrates and form click handlers fall back to native
|
||||
// submits — the symptom that bit us with a hard-coded IP. Wildcards
|
||||
// cover any LAN device without a per-network config edit.
|
||||
...(isProd ? {} : { allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*', '172.20.*.*'] }),
|
||||
// Native/CJS-leaning server-only packages — list here so Next doesn't
|
||||
// bundle them into the route trace (slower cold start + risk that
|
||||
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
|
||||
// is only imported by the custom server entry point, so the Next
|
||||
// tracer has no reason to include it; listing here makes the
|
||||
// dependency visible to the build system.
|
||||
serverExternalPackages: [
|
||||
'pino',
|
||||
'pino-pretty',
|
||||
@@ -11,13 +100,69 @@ const nextConfig: NextConfig = {
|
||||
'postgres',
|
||||
'better-auth',
|
||||
'nodemailer',
|
||||
'socket.io',
|
||||
'@socket.io/redis-adapter',
|
||||
'imapflow',
|
||||
'mailparser',
|
||||
'pdf-lib',
|
||||
'sharp',
|
||||
'tesseract.js',
|
||||
'@react-pdf/renderer',
|
||||
'unpdf',
|
||||
],
|
||||
images: {
|
||||
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
|
||||
},
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
typedRoutes: true,
|
||||
// ECharts ships ES modules that older Next/webpack versions can't parse
|
||||
// without a transpile-pass. Listing here is the official recommendation
|
||||
// from echarts-for-react when used inside Next.
|
||||
transpilePackages: ['echarts', 'zrender', 'echarts-for-react'],
|
||||
outputFileTracingIncludes: {
|
||||
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
|
||||
// runtime in the standalone build. Reading via fs.readFile from
|
||||
// process.cwd() requires the file to be traced explicitly.
|
||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/:portSlug/documents/files',
|
||||
destination: '/:portSlug/documents',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/:portSlug/documents/files/:path*',
|
||||
destination: '/:portSlug/documents',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// Sentry wrapper is conditional: if NEXT_PUBLIC_SENTRY_DSN isn't set we
|
||||
// skip its build-time source-map upload + middleware injection so dev
|
||||
// builds stay fast and CI doesn't need credentials. When the DSN is
|
||||
// present, withSentryConfig adds instrumentation hooks that route
|
||||
// errors + traces to Sentry.
|
||||
const withSentry = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? (cfg: NextConfig) =>
|
||||
withSentryConfig(cfg, {
|
||||
silent: true,
|
||||
widenClientFileUpload: true,
|
||||
// We host on our own infra — disable Vercel-specific tunneling.
|
||||
tunnelRoute: undefined,
|
||||
// Strip Sentry debug logger from prod bundle.
|
||||
disableLogger: true,
|
||||
})
|
||||
: (cfg: NextConfig) => cfg;
|
||||
|
||||
export default withSentry(withBundleAnalyzer(nextConfig));
|
||||
|
||||
@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
|
||||
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
|
||||
# input at the edge means a future regression cannot reopen the bypass.
|
||||
proxy_set_header X-Middleware-Subrequest "";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
198
package.json
198
package.json
@@ -2,35 +2,49 @@
|
||||
"name": "port-nimara-crm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack -H 0.0.0.0",
|
||||
"build": "next build && pnpm build:server",
|
||||
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "tsx scripts/db-migrate.ts apply",
|
||||
"db:migrate:status": "tsx scripts/db-migrate.ts status",
|
||||
"db:migrate:baseline": "tsx scripts/db-migrate.ts baseline",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/lib/db/seed.ts",
|
||||
"prepare": "husky"
|
||||
"db:seed:realistic": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts",
|
||||
"db:seed:wide-synthetic": "tsx src/lib/db/seed-wide-synthetic.ts",
|
||||
"db:reset": "tsx scripts/db-reset.ts --confirm",
|
||||
"db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic",
|
||||
"db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic",
|
||||
"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --project=smoke",
|
||||
"test:e2e:exhaustive": "playwright test --project=exhaustive",
|
||||
"test:e2e:destructive": "playwright test --project=destructive",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@pdfme/common": "^5.5.8",
|
||||
"@pdfme/generator": "^5.5.8",
|
||||
"@pdfme/schemas": "^5.5.8",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@@ -44,66 +58,128 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.53.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"better-auth": "^1.2.0",
|
||||
"bullmq": "^5.25.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@umami/node": "^0.4.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.6.11",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"bullmq": "^5.76.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"imapflow": "^1.2.13",
|
||||
"ioredis": "^5.4.0",
|
||||
"jose": "^6.2.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mailparser": "^3.9.4",
|
||||
"minio": "^8.0.0",
|
||||
"next": "15.1.0",
|
||||
"next-themes": "^0.4.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"recharts": "^3.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
"docx-preview": "^0.3.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"imapflow": "^1.3.3",
|
||||
"ioredis": "^5.10.1",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jose": "^6.2.3",
|
||||
"libphonenumber-js": "^1.13.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mailparser": "^3.9.8",
|
||||
"minio": "^8.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.7",
|
||||
"openai": "^6.37.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-queue": "^9.2.0",
|
||||
"p-retry": "^8.0.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pdfkit": "^0.18.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-email": "^6.1.3",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"recharts": "^3.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"svgo": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unpdf": "^1.6.2",
|
||||
"vaul": "^1.1.2",
|
||||
"web-vitals": "^5.2.0",
|
||||
"yet-another-react-lightbox": "^3.32.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hookform/devtools": "^4.4.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/iso-3166-2": "^1.0.4",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"esbuild": "^0.25.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^15.2.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.0"
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"prettier": "^3.8.3",
|
||||
"react-grab": "^0.1.34",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "8.0.5",
|
||||
"esbuild": ">=0.25.0",
|
||||
"postcss": ">=8.5.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/smoke',
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
@@ -22,17 +22,77 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /global-setup\.ts/,
|
||||
testMatch: /smoke\/global-setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'smoke',
|
||||
testMatch: /\d{2}-.*\.spec\.ts/,
|
||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exhaustive',
|
||||
testMatch: /exhaustive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'destructive',
|
||||
testMatch: /destructive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Real-API tests hit live external services (Documenso, IMAP, etc.).
|
||||
// Opt-in only: pnpm exec playwright test --project=realapi
|
||||
name: 'realapi',
|
||||
testMatch: /realapi\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
timeout: 120_000,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Visual regression baselines. Regenerate with --update-snapshots after
|
||||
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
|
||||
// diffs against the committed PNGs.
|
||||
name: 'visual',
|
||||
testMatch: /visual\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||
name: 'mobile-audit',
|
||||
testMatch: /audit\/mobile\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
|
||||
// 30 min headroom keeps us well under the wall-clock cost.
|
||||
timeout: 1_800_000,
|
||||
use: {
|
||||
headless: false,
|
||||
launchOptions: { slowMo: 200 },
|
||||
screenshot: 'off',
|
||||
video: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Don't start the dev server — we expect it to already be running
|
||||
|
||||
11571
pnpm-lock.yaml
generated
11571
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
BIN
public/Overhead_1_blur.png
Normal file
BIN
public/Overhead_1_blur.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
BIN
public/Port Nimara New Logo-Circular Frame_250px.png
Normal file
BIN
public/Port Nimara New Logo-Circular Frame_250px.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
BIN
public/icon-512-maskable.png
Normal file
BIN
public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Port Nimara CRM",
|
||||
"short_name": "Port Nimara",
|
||||
"description": "Marina/port management CRM",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f2f2",
|
||||
"theme_color": "#0f172a",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
32100
public/world-map/echarts-world.json
Normal file
32100
public/world-map/echarts-world.json
Normal file
File diff suppressed because it is too large
Load Diff
184
scripts/audit-permissions.ts
Normal file
184
scripts/audit-permissions.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Permission-matrix audit.
|
||||
*
|
||||
* Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP
|
||||
* handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission().
|
||||
* Internal v1 routes should be permission-gated; routes that intentionally use
|
||||
* withAuth() alone (e.g. user-self endpoints) can be allow-listed below.
|
||||
*
|
||||
* Run:
|
||||
* pnpm tsx scripts/audit-permissions.ts
|
||||
*
|
||||
* Exit code:
|
||||
* 0 — every handler is permission-gated or in the allow-list
|
||||
* 1 — at least one handler is missing both a withPermission wrapper and an
|
||||
* allow-list entry. CI should fail.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = join(process.cwd(), 'src/app/api/v1');
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||
|
||||
/**
|
||||
* Routes intentionally exempt from withPermission. Each entry should explain
|
||||
* why — typically because the route operates on the caller's own resources
|
||||
* (no port-level permission semantics) or is admin-only and gated by
|
||||
* isSuperAdmin inside the handler.
|
||||
*/
|
||||
const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
|
||||
// Self / admin / public
|
||||
{ pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' },
|
||||
{ pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' },
|
||||
{
|
||||
pattern: /\/notifications\//,
|
||||
reason: 'User-scoped notifications — caller is the resource owner.',
|
||||
},
|
||||
{ pattern: /\/socket\//, reason: 'Socket auth handshake.' },
|
||||
{ pattern: /\/health\//, reason: 'Public health check.' },
|
||||
{ pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' },
|
||||
{ pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' },
|
||||
{
|
||||
pattern: /\/settings\/feature-flag\//,
|
||||
reason: 'Public read of feature-flag bool — no PII; auth is sufficient.',
|
||||
},
|
||||
// Cross-cutting / port-scoped reference data
|
||||
{ pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' },
|
||||
{
|
||||
pattern: /\/currency\/(convert|rates)\/route\.ts$/,
|
||||
reason: 'Currency reference data; port-scoped, no PII.',
|
||||
},
|
||||
{
|
||||
pattern: /\/currency\/rates\/refresh\//,
|
||||
reason: 'TODO: gate with admin:manage_settings — currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/search\//,
|
||||
reason: 'Port-scoped search — results filtered by auth context (resources have own perms).',
|
||||
},
|
||||
// Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource.
|
||||
{ pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' },
|
||||
// Internally gated by isSuperAdmin
|
||||
{
|
||||
pattern: /\/expenses\/export\/parent-company\//,
|
||||
reason: 'Internally gated by isSuperAdmin inside the handler.',
|
||||
},
|
||||
// Pending dedicated permissions
|
||||
{
|
||||
pattern: /\/ai\//,
|
||||
reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/custom-fields\/\[entityId\]\//,
|
||||
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
method: string;
|
||||
reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed';
|
||||
allowReason?: string;
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(path);
|
||||
else if (entry.isFile() && entry.name === 'route.ts') yield path;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowListed(file: string): { allowed: boolean; reason?: string } {
|
||||
for (const { pattern, reason } of ALLOW_LIST) {
|
||||
if (pattern.test(file)) return { allowed: true, reason };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
async function auditFile(file: string): Promise<Finding[]> {
|
||||
const src = await readFile(file, 'utf-8');
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const method of HTTP_METHODS) {
|
||||
// Match: export const GET = withAuth(...
|
||||
const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's');
|
||||
const m = declRe.exec(src);
|
||||
if (!m) continue;
|
||||
const block = m[1] ?? '';
|
||||
|
||||
const hasAuth = /withAuth\s*\(/.test(block);
|
||||
const hasPerm = /withPermission\s*\(/.test(block);
|
||||
const allow = isAllowListed(file);
|
||||
|
||||
if (!hasAuth) {
|
||||
findings.push({ file, method, reason: 'no-withAuth' });
|
||||
continue;
|
||||
}
|
||||
if (!hasPerm) {
|
||||
if (allow.allowed) {
|
||||
findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason });
|
||||
} else {
|
||||
findings.push({ file, method, reason: 'no-withPermission' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files: string[] = [];
|
||||
for await (const f of walk(ROOT)) files.push(f);
|
||||
files.sort();
|
||||
|
||||
const all: Finding[] = [];
|
||||
for (const f of files) all.push(...(await auditFile(f)));
|
||||
|
||||
const violations = all.filter(
|
||||
(f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth',
|
||||
);
|
||||
const allowListed = all.filter((f) => f.reason === 'allow-listed');
|
||||
|
||||
// Markdown report
|
||||
const lines: string[] = [];
|
||||
lines.push('# Permission Matrix Audit');
|
||||
lines.push('');
|
||||
lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`);
|
||||
lines.push('');
|
||||
|
||||
if (violations.length === 0) {
|
||||
lines.push('**No violations.** Every internal v1 handler is permission-gated.');
|
||||
} else {
|
||||
lines.push(`**${violations.length} violation(s):**`);
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Issue |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const v of violations) {
|
||||
const rel = relative(process.cwd(), v.file);
|
||||
lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`,
|
||||
);
|
||||
if (allowListed.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Reason |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const a of allowListed) {
|
||||
const rel = relative(process.cwd(), a.file);
|
||||
lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
process.exit(violations.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
246
scripts/backfill-document-folders.ts
Normal file
246
scripts/backfill-document-folders.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Idempotent backfill: ensure every port has the three system roots
|
||||
* (Clients / Companies / Yachts), every entity with attached files
|
||||
* has a per-entity subfolder, every file with entity FKs has
|
||||
* `folder_id` set, and every signed file from a completed workflow
|
||||
* has the workflow's entity FKs propagated onto it.
|
||||
*
|
||||
* Safe to re-run: all writes target only rows where the relevant
|
||||
* column is NULL. Per-port `pg_advisory_xact_lock` serializes
|
||||
* concurrent runs.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-document-folders.ts
|
||||
* pnpm tsx scripts/backfill-document-folders.ts --port <portId>
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { files, documents } from '@/lib/db/schema/documents';
|
||||
import {
|
||||
ensureSystemRoots,
|
||||
ensureEntityFolder,
|
||||
type EntityType,
|
||||
} from '@/lib/services/document-folders.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface BackfillOptions {
|
||||
/** When provided, only backfill this port. Otherwise all ports. */
|
||||
portId?: string;
|
||||
/** User ID recorded in `created_by` for any folders created. */
|
||||
systemUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-port counters surfaced through the return value so the CLI can
|
||||
* print them and operators (or follow-up scripts) can sanity-check that
|
||||
* a re-run shrinks each number toward zero.
|
||||
*/
|
||||
export interface PortBackfillStats {
|
||||
portId: string;
|
||||
/** Total files inspected at Step 3 (with `folderId IS NULL`). */
|
||||
filesProcessed: number;
|
||||
/** Files updated with `folder_id` set in Step 3. */
|
||||
filesWithFolderIdSet: number;
|
||||
/** New folder rows created via `ensureEntityFolder` during Step 3. */
|
||||
foldersCreated: number;
|
||||
/** Completed-doc rows whose signed file got an entity FK propagated in Step 2. */
|
||||
fksPropagated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time idempotent backfill. See module-level JSDoc for full
|
||||
* description of what each step does.
|
||||
*/
|
||||
export async function runBackfill(opts: BackfillOptions = {}): Promise<PortBackfillStats[]> {
|
||||
const portRows = opts.portId
|
||||
? [{ id: opts.portId }]
|
||||
: await db.select({ id: ports.id }).from(ports);
|
||||
|
||||
const systemUser = opts.systemUserId ?? 'system-backfill';
|
||||
const allStats: PortBackfillStats[] = [];
|
||||
|
||||
for (const { id: portId } of portRows) {
|
||||
const stats: PortBackfillStats = {
|
||||
portId,
|
||||
filesProcessed: 0,
|
||||
filesWithFolderIdSet: 0,
|
||||
foldersCreated: 0,
|
||||
fksPropagated: 0,
|
||||
};
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize concurrent runs on a per-port lock so two simultaneous
|
||||
// backfills can't race on folder inserts.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId})::bigint)`);
|
||||
|
||||
// ── Step 1: Ensure system roots exist for this port ──────────────────
|
||||
await ensureSystemRoots(portId, systemUser);
|
||||
|
||||
// ── Step 2: Propagate entity FKs from completed workflows onto their
|
||||
// signed file rows (pre-auto-deposit legacy completions). ──
|
||||
const completedDocs = await tx
|
||||
.select({
|
||||
id: documents.id,
|
||||
signedFileId: documents.signedFileId,
|
||||
clientId: documents.clientId,
|
||||
companyId: documents.companyId,
|
||||
yachtId: documents.yachtId,
|
||||
})
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, portId),
|
||||
eq(documents.status, 'completed'),
|
||||
isNotNull(documents.signedFileId),
|
||||
),
|
||||
);
|
||||
|
||||
for (const d of completedDocs) {
|
||||
if (!d.signedFileId) continue;
|
||||
|
||||
const owner: { type: EntityType; id: string } | null = d.clientId
|
||||
? { type: 'client', id: d.clientId }
|
||||
: d.companyId
|
||||
? { type: 'company', id: d.companyId }
|
||||
: d.yachtId
|
||||
? { type: 'yacht', id: d.yachtId }
|
||||
: null;
|
||||
|
||||
if (!owner) continue;
|
||||
|
||||
// Build the update object with ONLY the matching FK column so we
|
||||
// never pass column references to .set() (Drizzle syntax bug fix).
|
||||
const update =
|
||||
owner.type === 'client'
|
||||
? { clientId: owner.id }
|
||||
: owner.type === 'company'
|
||||
? { companyId: owner.id }
|
||||
: { yachtId: owner.id };
|
||||
|
||||
const matchingFkColumn =
|
||||
owner.type === 'client'
|
||||
? files.clientId
|
||||
: owner.type === 'company'
|
||||
? files.companyId
|
||||
: files.yachtId;
|
||||
|
||||
const propagated = await tx
|
||||
.update(files)
|
||||
.set(update)
|
||||
.where(
|
||||
and(eq(files.id, d.signedFileId), eq(files.portId, portId), isNull(matchingFkColumn)),
|
||||
)
|
||||
.returning({ id: files.id });
|
||||
stats.fksPropagated += propagated.length;
|
||||
}
|
||||
|
||||
// ── Step 3: For every file with entity FKs but no folder_id,
|
||||
// create the entity subfolder and set folder_id. ──────────
|
||||
const fileRows = await tx
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.portId, portId), isNull(files.folderId)));
|
||||
stats.filesProcessed = fileRows.length;
|
||||
|
||||
const folderIdsCreatedThisRun = new Set<string>();
|
||||
const folderIdsSeenThisRun = new Set<string>();
|
||||
for (const f of fileRows) {
|
||||
const owner: { type: EntityType; id: string } | null = f.clientId
|
||||
? { type: 'client', id: f.clientId }
|
||||
: f.companyId
|
||||
? { type: 'company', id: f.companyId }
|
||||
: f.yachtId
|
||||
? { type: 'yacht', id: f.yachtId }
|
||||
: null;
|
||||
|
||||
if (!owner) continue;
|
||||
|
||||
try {
|
||||
const beforeExisted = folderIdsSeenThisRun.has(`${owner.type}:${owner.id}`);
|
||||
const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser);
|
||||
folderIdsSeenThisRun.add(`${owner.type}:${owner.id}`);
|
||||
if (!beforeExisted && !folderIdsCreatedThisRun.has(folder.id)) {
|
||||
// Heuristic: first time we encountered this entity in this
|
||||
// backfill run + the folder is freshly returned ⇒ assume the
|
||||
// folder was created (or existed already but we're double-
|
||||
// counting at most once per entity, which is fine).
|
||||
folderIdsCreatedThisRun.add(folder.id);
|
||||
}
|
||||
await tx
|
||||
.update(files)
|
||||
.set({ folderId: folder.id })
|
||||
.where(and(eq(files.id, f.id), eq(files.portId, portId)));
|
||||
stats.filesWithFolderIdSet += 1;
|
||||
} catch (err) {
|
||||
// Best-effort: log and skip rather than abort the whole port.
|
||||
logger.warn({ err, fileId: f.id, portId }, 'backfill: ensureEntityFolder failed');
|
||||
}
|
||||
}
|
||||
stats.foldersCreated = folderIdsCreatedThisRun.size;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
portId,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesWithFolderIdSet: stats.filesWithFolderIdSet,
|
||||
foldersCreated: stats.foldersCreated,
|
||||
fksPropagated: stats.fksPropagated,
|
||||
},
|
||||
'backfill: port complete',
|
||||
);
|
||||
allStats.push(stats);
|
||||
}
|
||||
return allStats;
|
||||
}
|
||||
|
||||
// ── CLI entry point ────────────────────────────────────────────────────────────
|
||||
// tsx compiles TypeScript to CJS at runtime, so `require.main === module`
|
||||
// is the standard guard. The test suite imports `runBackfill` as a named
|
||||
// export; the CLI invocation hits this block and runs main().
|
||||
|
||||
if (require.main === module) {
|
||||
const portIdArg = process.argv.indexOf('--port');
|
||||
let portId: string | undefined;
|
||||
if (portIdArg !== -1) {
|
||||
const next = process.argv[portIdArg + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
logger.error('--port requires a value');
|
||||
process.exit(1);
|
||||
}
|
||||
portId = next;
|
||||
}
|
||||
runBackfill({ portId })
|
||||
.then((stats) => {
|
||||
console.log('\nBackfill complete.');
|
||||
console.log('Per-port summary:');
|
||||
let totalFiles = 0;
|
||||
let totalFilesSet = 0;
|
||||
let totalFolders = 0;
|
||||
let totalFks = 0;
|
||||
for (const s of stats) {
|
||||
totalFiles += s.filesProcessed;
|
||||
totalFilesSet += s.filesWithFolderIdSet;
|
||||
totalFolders += s.foldersCreated;
|
||||
totalFks += s.fksPropagated;
|
||||
console.log(
|
||||
` port=${s.portId}: filesProcessed=${s.filesProcessed} ` +
|
||||
`filesWithFolderIdSet=${s.filesWithFolderIdSet} ` +
|
||||
`foldersCreated=${s.foldersCreated} fksPropagated=${s.fksPropagated}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Totals: ports=${stats.length} filesProcessed=${totalFiles} ` +
|
||||
`filesWithFolderIdSet=${totalFilesSet} foldersCreated=${totalFolders} ` +
|
||||
`fksPropagated=${totalFks}`,
|
||||
);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, 'Backfill failed');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
158
scripts/backfill-eoi-signers.ts
Normal file
158
scripts/backfill-eoi-signers.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Backfill `document_signers` rows for EOI documents that were generated
|
||||
* before the per-recipient signer-row insert landed (pre-2026-05-15).
|
||||
*
|
||||
* Symptom on the affected docs: the EOI tab's "Signing progress" panel
|
||||
* reads "No signers loaded" forever because the webhook handler updates
|
||||
* existing rows (by token / email) and never inserts new ones.
|
||||
*
|
||||
* This script walks every documents row that has a documensoId, status
|
||||
* in ('sent', 'partially_signed', 'completed'), and zero signer rows.
|
||||
* For each, it pulls the envelope from Documenso and recreates the
|
||||
* signer rows from the recipients array. Idempotent — safe to re-run.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-eoi-signers.ts # dry-run, lists candidates
|
||||
* pnpm tsx scripts/backfill-eoi-signers.ts --apply # actually inserts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { and, inArray, isNotNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db, closeDb } from '@/lib/db';
|
||||
import { documents, documentSigners } from '@/lib/db/schema/documents';
|
||||
import { getDocument as getDocumensoDoc } from '@/lib/services/documenso-client';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface BackfillStats {
|
||||
scanned: number;
|
||||
withZeroSigners: number;
|
||||
inserted: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
|
||||
// 1. Find candidate documents: in-flight or completed EOIs with a
|
||||
// documensoId and no signer rows.
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
portId: documents.portId,
|
||||
documensoId: documents.documensoId,
|
||||
status: documents.status,
|
||||
documentType: documents.documentType,
|
||||
title: documents.title,
|
||||
signerCount: sql<number>`(
|
||||
SELECT COUNT(*)::int FROM ${documentSigners}
|
||||
WHERE ${documentSigners.documentId} = ${documents.id}
|
||||
)`,
|
||||
})
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
inArray(documents.status, ['sent', 'partially_signed', 'completed']),
|
||||
isNotNull(documents.documensoId),
|
||||
),
|
||||
);
|
||||
|
||||
const stats: BackfillStats = {
|
||||
scanned: candidates.length,
|
||||
withZeroSigners: 0,
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
const needsBackfill = candidates.filter((c) => c.signerCount === 0);
|
||||
stats.withZeroSigners = needsBackfill.length;
|
||||
|
||||
console.log(
|
||||
`Scanned ${stats.scanned} document${stats.scanned === 1 ? '' : 's'}; ${stats.withZeroSigners} need backfill.`,
|
||||
);
|
||||
if (!apply) {
|
||||
console.log('\nDRY RUN (pass --apply to insert):');
|
||||
for (const doc of needsBackfill) {
|
||||
console.log(` - ${doc.id} (${doc.title}) — port=${doc.portId}, status=${doc.status}`);
|
||||
}
|
||||
await closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. For each candidate, fetch the envelope from Documenso and insert
|
||||
// the signer rows. Failures are logged + counted; processing
|
||||
// continues so one broken doc doesn't halt the run.
|
||||
for (const doc of needsBackfill) {
|
||||
if (!doc.documensoId) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const envelope = await getDocumensoDoc(doc.documensoId, doc.portId);
|
||||
if (envelope.recipients.length === 0) {
|
||||
logger.warn({ documentId: doc.id }, 'Backfill: envelope has no recipients — skipping');
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the same role-mapping logic as the create-time flow:
|
||||
// - signingOrder=1 + role SIGNER → 'client' (positional)
|
||||
// - SIGNER otherwise → 'signer'
|
||||
// - APPROVER → 'approver'
|
||||
// - CC / VIEWER → pass-through
|
||||
const rows = envelope.recipients.map((r) => {
|
||||
const cleanName = (r.name || r.email)
|
||||
.replace(/\s*\(was:[^)]*\)/i, '')
|
||||
.replace(/\s*\(placeholder\)/i, '')
|
||||
.trim();
|
||||
const upRole = r.role.toUpperCase();
|
||||
const role =
|
||||
upRole === 'SIGNER' && r.signingOrder === 1
|
||||
? 'client'
|
||||
: upRole === 'APPROVER'
|
||||
? 'approver'
|
||||
: upRole === 'CC'
|
||||
? 'cc'
|
||||
: upRole === 'VIEWER'
|
||||
? 'viewer'
|
||||
: 'signer';
|
||||
return {
|
||||
documentId: doc.id,
|
||||
signerName: cleanName || r.email,
|
||||
signerEmail: r.email,
|
||||
signerRole: role,
|
||||
signingOrder: r.signingOrder,
|
||||
status: (r.status === 'SIGNED' ? 'signed' : 'pending') as 'signed' | 'pending',
|
||||
signingUrl: r.signingUrl ?? null,
|
||||
embeddedUrl: r.embeddedUrl ?? null,
|
||||
signingToken: r.token ?? null,
|
||||
// No invitedAt — the backfill can't reconstruct the original
|
||||
// dispatch timestamp. Reps see the card as "Not yet invited"
|
||||
// for any pending signer; clicking Send invitation re-stamps.
|
||||
invitedAt: null,
|
||||
};
|
||||
});
|
||||
|
||||
await db.insert(documentSigners).values(rows);
|
||||
stats.inserted += rows.length;
|
||||
console.log(` ✓ ${doc.id} (${doc.title}) — inserted ${rows.length} signer row(s)`);
|
||||
} catch (err) {
|
||||
stats.failed++;
|
||||
logger.error(
|
||||
{ err: err instanceof Error ? err.message : err, documentId: doc.id },
|
||||
'Backfill failed for document',
|
||||
);
|
||||
console.log(` ✗ ${doc.id} — ${err instanceof Error ? err.message : 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. inserted=${stats.inserted} failed=${stats.failed} skipped=${stats.skipped}`);
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
135
scripts/backfill-legacy-lead-source.ts
Normal file
135
scripts/backfill-legacy-lead-source.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
|
||||
*
|
||||
* Why this exists: the legacy NocoDB Interests table left the `Source`
|
||||
* column null for ~95 % of rows. The migration mapped null → null, so the
|
||||
* Lead Source Attribution chart shows them as "Unspecified". Per the
|
||||
* operator's best knowledge, almost all of those legacy rows came in
|
||||
* through the website (web form / portal) — the few that didn't are the
|
||||
* ones that already carry an explicit `Source` value (Form / portal /
|
||||
* External). Defaulting null → 'website' is therefore the closest
|
||||
* truth we can reconstruct without per-row sales notes review.
|
||||
*
|
||||
* Idempotent: only updates rows where `source IS NULL` AND the row has a
|
||||
* `migration_source_links` entry tying it back to the legacy NocoDB import,
|
||||
* so net-new manually-created interests with null source aren't touched.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, isNull, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string | null;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { portSlug: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(
|
||||
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, name: ports.name })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug!))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[backfill] target: ${port.name} (${port.id})`);
|
||||
|
||||
// Pull every interest id this port owns that has a NULL source.
|
||||
const candidateInterests = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
|
||||
|
||||
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
|
||||
|
||||
if (candidateInterests.length === 0) {
|
||||
console.log('Nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to ONLY those that came in via the legacy migration — preserves
|
||||
// null on net-new rows where the operator hasn't picked a source yet.
|
||||
const candidateIds = candidateInterests.map((r) => r.id);
|
||||
const legacyLinks = await db
|
||||
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||
inArray(migrationSourceLinks.targetEntityId, candidateIds),
|
||||
),
|
||||
);
|
||||
|
||||
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
|
||||
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
|
||||
|
||||
console.log(
|
||||
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
|
||||
);
|
||||
console.log(
|
||||
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[backfill] --dry-run set; no writes.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update in chunks of 500 to keep query size sane.
|
||||
const CHUNK = 500;
|
||||
let updated = 0;
|
||||
for (let i = 0; i < toUpdate.length; i += CHUNK) {
|
||||
const chunk = toUpdate.slice(i, i + CHUNK);
|
||||
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
|
||||
// a concurrent process that set source on one of these rows
|
||||
// between SELECT and UPDATE doesn't get its value clobbered.
|
||||
const result = await db
|
||||
.update(interests)
|
||||
.set({ source: 'website' })
|
||||
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
|
||||
.returning({ id: interests.id });
|
||||
updated += result.length;
|
||||
}
|
||||
console.log(`[backfill] updated ${updated} rows.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL', err);
|
||||
process.exit(1);
|
||||
});
|
||||
83
scripts/backfill-nested-document-folders.ts
Normal file
83
scripts/backfill-nested-document-folders.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Phase 2 nested-subfolders backfill.
|
||||
*
|
||||
* Re-files every existing `files` row that has `entity_type='interest'`
|
||||
* (or a non-null `interest_id`) under a nested
|
||||
* `Clients/<Name>/<Interest folder>/` subfolder. Idempotent — already-
|
||||
* filed rows are skipped.
|
||||
*
|
||||
* Run dry-first to confirm the row count:
|
||||
* pnpm tsx scripts/backfill-nested-document-folders.ts
|
||||
*
|
||||
* Apply for real:
|
||||
* pnpm tsx scripts/backfill-nested-document-folders.ts --apply
|
||||
*
|
||||
* Per-port advisory lock so two operators can't race a backfill on the
|
||||
* same port. Lock id is the FNV-1a hash of `port_id` so concurrent
|
||||
* backfills against different ports don't block each other.
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '../src/lib/db';
|
||||
import { ensureEntityFolder } from '../src/lib/services/document-folders.service';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
function fnv1a(input: string): number {
|
||||
// Simple deterministic 32-bit hash — used as the advisory-lock id so
|
||||
// the lock is stable across runs. PostgreSQL accepts a bigint here.
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[backfill-nested-folders] dry-run=${!APPLY}`);
|
||||
|
||||
// 1. Gather every (port_id, interest_id) pair whose files need to be
|
||||
// nested. We only need to ensure the folder exists — the
|
||||
// `files.interest_id` column is populated separately by Phase 1.
|
||||
const rows = await db.execute<{ port_id: string; interest_id: string; row_count: number }>(
|
||||
sql`
|
||||
SELECT f.port_id, f.interest_id, COUNT(*)::int AS row_count
|
||||
FROM files f
|
||||
WHERE f.interest_id IS NOT NULL
|
||||
AND f.archived_at IS NULL
|
||||
GROUP BY f.port_id, f.interest_id
|
||||
ORDER BY f.port_id, f.interest_id
|
||||
`,
|
||||
);
|
||||
|
||||
// postgres-js returns the raw result iterable; the `.rows` property is
|
||||
// pgnative-only — iterate the result directly.
|
||||
const list = Array.isArray(rows) ? rows : ((rows as { rows?: typeof rows }).rows ?? rows);
|
||||
console.log(`[backfill-nested-folders] ${list.length} (port, interest) pairs to process`);
|
||||
for (const row of list as Array<{ port_id: string; interest_id: string; row_count: number }>) {
|
||||
const lockId = fnv1a(row.port_id);
|
||||
if (APPLY) {
|
||||
await db.execute(sql`SELECT pg_advisory_xact_lock(${lockId}::bigint)`);
|
||||
// ensureEntityFolder is idempotent — running it for a pair that
|
||||
// already has its folder is a cheap select.
|
||||
await ensureEntityFolder(row.port_id, 'interest', row.interest_id, 'system');
|
||||
}
|
||||
console.log(
|
||||
` ${APPLY ? '✓' : '·'} port=${row.port_id.slice(0, 8)} interest=${row.interest_id.slice(
|
||||
0,
|
||||
8,
|
||||
)} files=${row.row_count}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[backfill-nested-folders] done.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[backfill-nested-folders] failed', err);
|
||||
process.exit(1);
|
||||
});
|
||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||
* contacts where it's null or empty.
|
||||
*
|
||||
* The legacy seed (and pre-normalization production data) stored phone
|
||||
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||
* is what every UI surface and dedup matcher reads. This script runs the
|
||||
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||
* form back.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||
*
|
||||
* The dry-run report prints, for each unparseable row, the contact id +
|
||||
* raw value so you can hand-clean before re-running.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema/clients';
|
||||
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface PhoneRow {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string | null;
|
||||
valueCountry: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||
console.log('');
|
||||
|
||||
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||
// `value_e164` null/empty.
|
||||
const rows: PhoneRow[] = await db
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueCountry: clientContacts.valueCountry,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(` found ${rows.length} candidate rows`);
|
||||
|
||||
let parsedFull = 0;
|
||||
let parsedE164Only = 0;
|
||||
let unparseable = 0;
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
valueE164: string;
|
||||
valueCountry: CountryCode | null;
|
||||
}> = [];
|
||||
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value) continue;
|
||||
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||
|
||||
if (parsed1.e164 && parsed1.country) {
|
||||
// Both e164 + country resolved — best case.
|
||||
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||
parsedFull++;
|
||||
} else if (parsed1.e164) {
|
||||
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||
// fictional/reserved range — libphonenumber returns the e164 form
|
||||
// but refuses to assign a country). Still safe to write — the e164
|
||||
// is canonical. Country stays null.
|
||||
updates.push({
|
||||
id: row.id,
|
||||
valueE164: parsed1.e164,
|
||||
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||
});
|
||||
parsedE164Only++;
|
||||
} else {
|
||||
fails.push({
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
reason: row.value.trim().startsWith('+')
|
||||
? 'has + prefix but parse failed'
|
||||
: 'no leading + and no country hint',
|
||||
});
|
||||
unparseable++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||
console.log(' ✗ unparseable ', unparseable);
|
||||
console.log('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
console.log('Failures (first 10):');
|
||||
for (const f of fails.slice(0, 10)) {
|
||||
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No updates to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Writing ${updates.length} updates...`);
|
||||
|
||||
for (const u of updates) {
|
||||
await db
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
valueE164: u.valueE164,
|
||||
valueCountry: u.valueCountry,
|
||||
})
|
||||
.where(eq(clientContacts.id, u.id));
|
||||
}
|
||||
|
||||
console.log(` ✓ wrote ${updates.length} rows`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
51
scripts/backup/minio-mirror.sh
Normal file
51
scripts/backup/minio-mirror.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hourly MinIO mirror for Port Nimara CRM.
|
||||
#
|
||||
# Mirrors the live `MINIO_BUCKET` to the backup destination. `mc mirror`
|
||||
# is incremental — only changed objects transfer — so this is cheap.
|
||||
#
|
||||
# Versioning on the destination bucket is what protects against object
|
||||
# deletes / overwrites; we don't try to roll our own.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${MINIO_ENDPOINT:?MINIO_ENDPOINT not set}"
|
||||
: "${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY not set}"
|
||||
: "${MINIO_SECRET_KEY:?MINIO_SECRET_KEY not set}"
|
||||
: "${MINIO_BUCKET:?MINIO_BUCKET not set}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
# Default scheme: live MinIO is plain HTTP unless MINIO_USE_SSL=true.
|
||||
LIVE_URL="${MINIO_ENDPOINT}"
|
||||
if [[ "${MINIO_USE_SSL:-false}" == "true" ]]; then
|
||||
LIVE_URL="https://${MINIO_ENDPOINT}:${MINIO_PORT:-443}"
|
||||
else
|
||||
LIVE_URL="http://${MINIO_ENDPOINT}:${MINIO_PORT:-9000}"
|
||||
fi
|
||||
|
||||
LIVE_ALIAS="live-$$"
|
||||
BACKUP_ALIAS="bk-$$"
|
||||
trap 'mc alias remove "$LIVE_ALIAS" 2>/dev/null || true; mc alias remove "$BACKUP_ALIAS" 2>/dev/null || true' EXIT
|
||||
|
||||
mc alias set "$LIVE_ALIAS" "$LIVE_URL" \
|
||||
"$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 >/dev/null
|
||||
mc alias set "$BACKUP_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
|
||||
|
||||
SOURCE="${LIVE_ALIAS}/${MINIO_BUCKET}/"
|
||||
DEST="${BACKUP_ALIAS}/${BACKUP_S3_BUCKET}/minio/"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Mirroring $SOURCE → $DEST"
|
||||
|
||||
# `--remove` would delete objects from the destination that no longer
|
||||
# exist in source — we DON'T pass it, because that would let an
|
||||
# accidental delete on the live bucket cascade into permanent loss on
|
||||
# the backup side. Versioning + lifecycle handle stale-object cleanup.
|
||||
mc mirror --quiet --overwrite "$SOURCE" "$DEST"
|
||||
|
||||
# Print byte / count diff for the operator.
|
||||
echo "[$(date -u +%FT%TZ)] Done. Destination summary:"
|
||||
mc du "$DEST"
|
||||
63
scripts/backup/pg-backup.sh
Normal file
63
scripts/backup/pg-backup.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hourly PostgreSQL backup for Port Nimara CRM.
|
||||
#
|
||||
# Reads DATABASE_URL and BACKUP_S3_* from the environment. Dumps to a
|
||||
# tmpfile, gzips, optionally GPG-encrypts to BACKUP_GPG_RECIPIENT, and
|
||||
# uploads to s3://${BACKUP_S3_BUCKET}/pg/<hostname>/<UTC-date>/<hour>.dump.gz[.gpg].
|
||||
#
|
||||
# Designed to fail loud: any non-zero exit halts the script and propagates
|
||||
# to the cron / CI runner so the operator sees the failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${DATABASE_URL:?DATABASE_URL not set}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
|
||||
DATE_UTC="$(date -u +%Y-%m-%d)"
|
||||
HOUR_UTC="$(date -u +%H)"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
DUMP_FILE="$WORKDIR/${HOUR_UTC}.dump"
|
||||
ARCHIVE_NAME="${HOUR_UTC}.dump.gz"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Dumping $DATABASE_URL → $DUMP_FILE"
|
||||
pg_dump --format=custom --compress=9 --no-owner --no-privileges \
|
||||
--file="$DUMP_FILE" "$DATABASE_URL"
|
||||
|
||||
# pg_dump's `custom` format is already compressed, but we wrap in gzip so
|
||||
# the file looks the same regardless of the dump format on disk.
|
||||
gzip -n "$DUMP_FILE"
|
||||
GZ_FILE="${DUMP_FILE}.gz"
|
||||
|
||||
# Optional GPG layer. Only encrypt if the recipient is configured.
|
||||
if [[ -n "${BACKUP_GPG_RECIPIENT:-}" ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Encrypting for $BACKUP_GPG_RECIPIENT"
|
||||
gpg --batch --yes --trust-model always \
|
||||
--recipient "$BACKUP_GPG_RECIPIENT" \
|
||||
--encrypt --output "${GZ_FILE}.gpg" "$GZ_FILE"
|
||||
rm "$GZ_FILE"
|
||||
GZ_FILE="${GZ_FILE}.gpg"
|
||||
ARCHIVE_NAME="${ARCHIVE_NAME}.gpg"
|
||||
fi
|
||||
|
||||
# Configure mc client for the backup destination.
|
||||
MC_ALIAS="bk-$$"
|
||||
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" \
|
||||
--api S3v4 >/dev/null
|
||||
|
||||
REMOTE_PATH="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${DATE_UTC}/${ARCHIVE_NAME}"
|
||||
echo "[$(date -u +%FT%TZ)] Uploading → $REMOTE_PATH"
|
||||
mc cp --quiet "$GZ_FILE" "$REMOTE_PATH"
|
||||
|
||||
# Tag with retention metadata so lifecycle rules can decide what to expire.
|
||||
mc tag set "$REMOTE_PATH" "kind=hourly&host=${HOST}&date=${DATE_UTC}" >/dev/null
|
||||
|
||||
mc alias remove "$MC_ALIAS" >/dev/null
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] OK ${ARCHIVE_NAME} ($(du -h "$GZ_FILE" | cut -f1))"
|
||||
121
scripts/backup/restore.sh
Normal file
121
scripts/backup/restore.sh
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cold-restore script for Port Nimara CRM.
|
||||
#
|
||||
# Two modes:
|
||||
# --drill Restore to a sandbox DB ($DRILL_DATABASE_URL) + a tagged
|
||||
# sandbox path on the live MinIO bucket. Used by the weekly
|
||||
# cron drill so the runbook stays accurate.
|
||||
# (no --drill) Interactive production restore. Prompts before each
|
||||
# destructive step; refuses to run if the live DB has
|
||||
# non-empty tables (caller is expected to drop first).
|
||||
#
|
||||
# Common args:
|
||||
# --snapshot YYYY-MM-DD/HH Specific dump to restore. Defaults to "latest".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRILL=0
|
||||
SNAPSHOT="latest"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--drill) DRILL=1; shift ;;
|
||||
--snapshot) SNAPSHOT="$2"; shift 2 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
if [[ "$DRILL" -eq 1 ]]; then
|
||||
: "${DRILL_DATABASE_URL:?DRILL_DATABASE_URL not set}"
|
||||
TARGET_DB="$DRILL_DATABASE_URL"
|
||||
echo "[drill] target DB = $TARGET_DB"
|
||||
else
|
||||
: "${DATABASE_URL:?DATABASE_URL not set}"
|
||||
TARGET_DB="$DATABASE_URL"
|
||||
read -rp "About to overwrite $TARGET_DB. Type 'restore' to continue: " confirm
|
||||
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
|
||||
fi
|
||||
|
||||
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
MC_ALIAS="bk-$$"
|
||||
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
|
||||
trap 'rm -rf "$WORKDIR"; mc alias remove "$MC_ALIAS" 2>/dev/null || true' EXIT
|
||||
|
||||
# Resolve the snapshot path.
|
||||
if [[ "$SNAPSHOT" == "latest" ]]; then
|
||||
REMOTE=$(mc ls --recursive "${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/" \
|
||||
| awk '{print $NF}' | sort | tail -1)
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
echo "no snapshots found under ${BACKUP_S3_BUCKET}/pg/${HOST}/" >&2
|
||||
exit 1
|
||||
fi
|
||||
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${REMOTE}"
|
||||
else
|
||||
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${SNAPSHOT}.dump.gz"
|
||||
# If GPG was used, the file lives at .dump.gz.gpg. Try both.
|
||||
if ! mc stat "$REMOTE" >/dev/null 2>&1; then
|
||||
REMOTE="${REMOTE}.gpg"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Pulling $REMOTE"
|
||||
LOCAL="$WORKDIR/$(basename "$REMOTE")"
|
||||
mc cp --quiet "$REMOTE" "$LOCAL"
|
||||
|
||||
# Decrypt if needed.
|
||||
if [[ "$LOCAL" == *.gpg ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Decrypting"
|
||||
gpg --batch --yes --decrypt --output "${LOCAL%.gpg}" "$LOCAL"
|
||||
rm "$LOCAL"
|
||||
LOCAL="${LOCAL%.gpg}"
|
||||
fi
|
||||
|
||||
# Decompress.
|
||||
gunzip "$LOCAL"
|
||||
LOCAL="${LOCAL%.gz}"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Restoring into $TARGET_DB"
|
||||
|
||||
# Drop & recreate to guarantee no half-state from a prior run.
|
||||
DB_NAME=$(echo "$TARGET_DB" | sed -E 's|.*/([^?]+).*|\1|')
|
||||
ADMIN_URL=$(echo "$TARGET_DB" | sed -E "s|/${DB_NAME}|/postgres|")
|
||||
|
||||
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 <<SQL
|
||||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
|
||||
DROP DATABASE IF EXISTS "${DB_NAME}";
|
||||
CREATE DATABASE "${DB_NAME}";
|
||||
SQL
|
||||
|
||||
pg_restore --no-owner --no-privileges --dbname "$TARGET_DB" "$LOCAL"
|
||||
|
||||
# Drill mode: compare row counts vs the live producer for parity.
|
||||
if [[ "$DRILL" -eq 1 ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Drill row-count diff (live vs restored):"
|
||||
TABLES=$(psql -At "$TARGET_DB" -c \
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;")
|
||||
diff_count=0
|
||||
while IFS= read -r tbl; do
|
||||
[[ -z "$tbl" ]] && continue
|
||||
live=$(psql -At "${LIVE_DATABASE_URL:-$DATABASE_URL}" -c "SELECT count(*) FROM \"$tbl\";")
|
||||
restored=$(psql -At "$TARGET_DB" -c "SELECT count(*) FROM \"$tbl\";")
|
||||
delta=$((live - restored))
|
||||
if [[ "$delta" -ne 0 ]]; then
|
||||
echo " ⚠ $tbl: live=$live restored=$restored delta=$delta"
|
||||
diff_count=$((diff_count + 1))
|
||||
fi
|
||||
done <<< "$TABLES"
|
||||
if [[ "$diff_count" -eq 0 ]]; then
|
||||
echo " ✓ row counts match across all tables"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Restore complete."
|
||||
275
scripts/db-migrate.ts
Normal file
275
scripts/db-migrate.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Production migration runner.
|
||||
*
|
||||
* Why this exists (and why `drizzle-kit migrate` isn't enough):
|
||||
*
|
||||
* - Drizzle's bundled `migrate()` wraps every migration in a single
|
||||
* transaction. Postgres forbids `CREATE INDEX CONCURRENTLY` inside
|
||||
* a transaction (raises 25001) — so any migration containing
|
||||
* CONCURRENTLY silently aborts or, worse, leaves the migration
|
||||
* marked applied with the index missing. `0052_audit_critical_fixes.sql`
|
||||
* ships six CONCURRENTLY composite indexes today and they never
|
||||
* landed in prod.
|
||||
*
|
||||
* - `drizzle-kit push` skips DDL the kit can't infer from the schema —
|
||||
* e.g. CHECK constraints, partial unique indexes, the berth-pdf
|
||||
* circular FK. push-only deployments diverge from migration-tracked
|
||||
* truth.
|
||||
*
|
||||
* This script:
|
||||
* 1. Reads migrations in journal order from `src/lib/db/migrations`.
|
||||
* 2. Tracks applied state in `drizzle.__drizzle_migrations` (matching
|
||||
* Drizzle's schema so other tooling sees the same source of truth).
|
||||
* 3. For each pending migration: splits on `--> statement-breakpoint`,
|
||||
* classifies each statement as concurrency-safe (CREATE INDEX
|
||||
* CONCURRENTLY / REINDEX CONCURRENTLY → outside tx) or
|
||||
* transactional (everything else → batched in one tx per migration).
|
||||
* 4. Records hash + when-applied so re-runs are no-ops.
|
||||
*
|
||||
* Modes:
|
||||
* `pnpm db:migrate` — apply pending migrations
|
||||
* `pnpm db:migrate:status` — show pending vs applied without applying
|
||||
* `pnpm db:migrate:baseline` — mark every migration as applied without
|
||||
* running it. Use ONCE per environment when
|
||||
* the schema was bootstrapped via `db:push`
|
||||
* (dev + the original prod cutover). After
|
||||
* baseline, all future migrations go through
|
||||
* `db:migrate` and are tracked in
|
||||
* `__drizzle_migrations`.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const STATEMENT_BREAKPOINT = '--> statement-breakpoint';
|
||||
const MIGRATIONS_DIR = join(process.cwd(), 'src/lib/db/migrations');
|
||||
const SCHEMA_NAME = 'drizzle';
|
||||
const TABLE_NAME = '__drizzle_migrations';
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
interface Journal {
|
||||
version: string;
|
||||
dialect: string;
|
||||
entries: JournalEntry[];
|
||||
}
|
||||
|
||||
interface MigrationFile {
|
||||
tag: string;
|
||||
/** Folder millis from journal `when` — Drizzle uses this as the
|
||||
* primary key in `__drizzle_migrations`. */
|
||||
folderMillis: number;
|
||||
/** Full file contents. */
|
||||
sql: string;
|
||||
/** SHA-256 hex of the raw file for re-application detection. */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
interface Statement {
|
||||
/** Raw SQL text (trimmed). */
|
||||
sql: string;
|
||||
/** True when the statement must execute outside a transaction. */
|
||||
needsAutocommit: boolean;
|
||||
}
|
||||
|
||||
function isConcurrencyDDL(sql: string): boolean {
|
||||
const head = sql
|
||||
.replace(/^\s*--.*$/gm, '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return (
|
||||
/\bCREATE\s+INDEX\s+CONCURRENTLY\b/.test(head) ||
|
||||
/\bREINDEX\s+\w*\s*CONCURRENTLY\b/.test(head) ||
|
||||
/\bDROP\s+INDEX\s+CONCURRENTLY\b/.test(head)
|
||||
);
|
||||
}
|
||||
|
||||
function readMigrations(): MigrationFile[] {
|
||||
const journal = JSON.parse(
|
||||
readFileSync(join(MIGRATIONS_DIR, 'meta', '_journal.json'), 'utf8'),
|
||||
) as Journal;
|
||||
|
||||
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
|
||||
const byTag = new Map(files.map((f) => [f.replace(/\.sql$/, ''), f]));
|
||||
|
||||
return journal.entries.map((entry) => {
|
||||
const filename = byTag.get(entry.tag);
|
||||
if (!filename) {
|
||||
throw new Error(`Migration ${entry.tag} in journal but ${entry.tag}.sql not on disk`);
|
||||
}
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8');
|
||||
const hash = createHash('sha256').update(sql).digest('hex');
|
||||
return { tag: entry.tag, folderMillis: entry.when, sql, hash };
|
||||
});
|
||||
}
|
||||
|
||||
function splitStatements(sql: string): Statement[] {
|
||||
// Drizzle inserts `--> statement-breakpoint` between every statement
|
||||
// when `breakpoints: true` in drizzle.config. We split on those AND
|
||||
// strip trailing semicolons. Anything before the first breakpoint
|
||||
// counts too.
|
||||
const parts = sql.split(STATEMENT_BREAKPOINT);
|
||||
const out: Statement[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed || trimmed.startsWith('--')) {
|
||||
// Comment-only chunks (pre-breakpoint header etc.) — skip if
|
||||
// they have no executable SQL.
|
||||
const nonComment = trimmed
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--') && line.trim().length > 0);
|
||||
if (nonComment.length === 0) continue;
|
||||
}
|
||||
out.push({ sql: trimmed, needsAutocommit: isConcurrencyDDL(trimmed) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function ensureMigrationsTable(sql: postgres.Sql): Promise<void> {
|
||||
await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA_NAME}"`);
|
||||
await sql.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${SCHEMA_NAME}"."${TABLE_NAME}" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function getAppliedHashes(sql: postgres.Sql): Promise<Set<string>> {
|
||||
const rows = await sql.unsafe<{ hash: string }[]>(
|
||||
`SELECT hash FROM "${SCHEMA_NAME}"."${TABLE_NAME}"`,
|
||||
);
|
||||
return new Set(rows.map((r) => r.hash));
|
||||
}
|
||||
|
||||
async function applyMigration(sql: postgres.Sql, migration: MigrationFile): Promise<void> {
|
||||
const statements = splitStatements(migration.sql);
|
||||
if (statements.length === 0) {
|
||||
console.log(` [${migration.tag}] no executable statements, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const autocommit = statements.filter((s) => s.needsAutocommit);
|
||||
const transactional = statements.filter((s) => !s.needsAutocommit);
|
||||
|
||||
// Transactional batch first — schema changes that CONCURRENTLY ops
|
||||
// depend on (e.g. column adds before CREATE INDEX) need to exist
|
||||
// before the index build runs. Drizzle migrations are written in
|
||||
// this order; we preserve it within each phase.
|
||||
if (transactional.length > 0) {
|
||||
await sql.begin(async (tx) => {
|
||||
for (const stmt of transactional) {
|
||||
await tx.unsafe(stmt.sql);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// CONCURRENTLY ops run one at a time, each as its own implicit tx.
|
||||
// No `BEGIN`/`COMMIT` wrapping — postgres-js's `sql.unsafe` runs
|
||||
// each call as an independent transaction.
|
||||
for (const stmt of autocommit) {
|
||||
await sql.unsafe(stmt.sql);
|
||||
}
|
||||
|
||||
// Record the migration as applied. created_at mirrors Drizzle's own
|
||||
// schema so `drizzle-kit migrate` (if ever invoked) sees the same
|
||||
// state we wrote.
|
||||
await sql.unsafe(
|
||||
`INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
|
||||
[migration.hash, migration.folderMillis],
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = process.argv[2] ?? 'apply';
|
||||
if (!['apply', 'status', 'baseline'].includes(mode)) {
|
||||
console.error(`Unknown mode: ${mode}. Use 'apply' (default), 'status', or 'baseline'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1, prepare: false });
|
||||
|
||||
try {
|
||||
await ensureMigrationsTable(sql);
|
||||
const applied = await getAppliedHashes(sql);
|
||||
const migrations = readMigrations();
|
||||
const pending = migrations.filter((m) => !applied.has(m.hash));
|
||||
|
||||
if (mode === 'status') {
|
||||
console.log(`Applied: ${applied.size}`);
|
||||
console.log(`Pending: ${pending.length}`);
|
||||
if (pending.length > 0) {
|
||||
console.log('');
|
||||
console.log('Pending migrations:');
|
||||
for (const m of pending) {
|
||||
const statements = splitStatements(m.sql);
|
||||
const conc = statements.filter((s) => s.needsAutocommit).length;
|
||||
const tx = statements.length - conc;
|
||||
console.log(` ${m.tag} — ${tx} transactional + ${conc} concurrency-safe`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'baseline') {
|
||||
if (pending.length === 0) {
|
||||
console.log('All migrations already tracked. Nothing to baseline.');
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Baselining ${pending.length} migration${
|
||||
pending.length === 1 ? '' : 's'
|
||||
} as applied without running them.`,
|
||||
);
|
||||
console.log(
|
||||
'This is correct ONLY when the schema is already in place (e.g. created via db:push).',
|
||||
);
|
||||
for (const m of pending) {
|
||||
await sql.unsafe(
|
||||
`INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
|
||||
[m.hash, m.folderMillis],
|
||||
);
|
||||
console.log(` → ${m.tag} marked as applied`);
|
||||
}
|
||||
console.log(`Done. ${pending.length} baselined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('No pending migrations.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Applying ${pending.length} migration${pending.length === 1 ? '' : 's'}...`);
|
||||
for (const m of pending) {
|
||||
const statements = splitStatements(m.sql);
|
||||
const conc = statements.filter((s) => s.needsAutocommit).length;
|
||||
console.log(` → ${m.tag} (${statements.length} statements, ${conc} CONCURRENTLY)`);
|
||||
await applyMigration(sql, m);
|
||||
}
|
||||
console.log(`Done. ${pending.length} applied.`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
97
scripts/db-reset.ts
Normal file
97
scripts/db-reset.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Wipe all data from the database, preserving schema + drizzle migration
|
||||
* history. Run before swapping seed fixtures.
|
||||
*
|
||||
* pnpm tsx scripts/db-reset.ts (refuses without --confirm)
|
||||
* pnpm tsx scripts/db-reset.ts --confirm
|
||||
*
|
||||
* Truncates every table in the `public` schema except the drizzle
|
||||
* migration tracker, then resets sequences. Wraps the loop in a single
|
||||
* transaction so a mid-wipe failure rolls back cleanly.
|
||||
*
|
||||
* Refuses to run when DATABASE_URL points at anything that doesn't look
|
||||
* like a local/dev host. Override with --i-know-what-im-doing.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const url: string = process.env.DATABASE_URL ?? '';
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL is not set; aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (!args.has('--confirm')) {
|
||||
console.error('Refusing to wipe without --confirm');
|
||||
console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort safety: refuse for anything that doesn't look like a local DB.
|
||||
function looksLocal(u: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return (
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '::1' ||
|
||||
parsed.hostname.endsWith('.local') ||
|
||||
parsed.hostname.endsWith('.internal') ||
|
||||
parsed.hostname === 'host.docker.internal' ||
|
||||
// Docker compose service names commonly used here
|
||||
parsed.hostname === 'postgres' ||
|
||||
parsed.hostname === 'db'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) {
|
||||
console.error(
|
||||
`DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
async function main() {
|
||||
console.log('Resetting database...');
|
||||
console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`);
|
||||
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT LIKE 'drizzle_%'
|
||||
AND tablename != '__drizzle_migrations'
|
||||
`;
|
||||
|
||||
if (tables.length === 0) {
|
||||
console.log(' no user tables found, nothing to do.');
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single TRUNCATE … CASCADE is faster than per-table loops and handles
|
||||
// FK ordering for us. Quote table names defensively.
|
||||
const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
|
||||
|
||||
console.log(` truncating ${tables.length} tables...`);
|
||||
await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
|
||||
console.log(' done.');
|
||||
|
||||
await sql.end();
|
||||
console.log('');
|
||||
console.log('Database reset complete. Run a seed script next:');
|
||||
console.log(' pnpm db:seed # realistic NocoDB-shaped fixture');
|
||||
console.log(' pnpm db:seed:synthetic # one client per pipeline stage');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Reset failed:', err);
|
||||
await sql.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
102
scripts/dev-create-crm-user.ts
Normal file
102
scripts/dev-create-crm-user.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Dev-only helper: create (or upsert) a CRM better-auth user and mark them
|
||||
* super_admin. Idempotent — re-running with the same email will reset the
|
||||
* password.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main() {
|
||||
const [email, password, displayNameArg] = process.argv.slice(2);
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
'Usage: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = displayNameArg ?? email.split('@')[0] ?? 'User';
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
|
||||
try {
|
||||
// 1. Check if better-auth user already exists.
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
|
||||
`;
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existing.length > 0) {
|
||||
const row = existing[0];
|
||||
if (!row) throw new Error('unreachable');
|
||||
userId = row.id;
|
||||
console.log(`User ${email} exists (id=${userId}); resetting password.`);
|
||||
// Use better-auth's internal context to hash and update the credential.
|
||||
const ctx = await auth.$context;
|
||||
const hash = await ctx.password.hash(password);
|
||||
await sql`
|
||||
UPDATE account
|
||||
SET password = ${hash}, updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND provider_id = 'credential'
|
||||
`;
|
||||
} else {
|
||||
console.log(`Creating better-auth user ${email}…`);
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email, password, name: displayName },
|
||||
});
|
||||
userId = result.user.id;
|
||||
console.log(`Created user_id=${userId}`);
|
||||
}
|
||||
|
||||
// 2. Upsert user_profiles entry as super admin.
|
||||
const profile = await db
|
||||
.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (profile.length === 0) {
|
||||
await db.insert(userProfiles).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
});
|
||||
console.log(`Created super_admin profile for ${userId}`);
|
||||
} else {
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ displayName, isSuperAdmin: true, isActive: true })
|
||||
.where(eq(userProfiles.userId, userId));
|
||||
console.log(`Updated profile for ${userId} (super_admin=true)`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`✓ Done. Sign in at http://localhost:3000/login with`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` password: ${password}`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
66
scripts/dev-imap-probe.ts
Normal file
66
scripts/dev-imap-probe.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
|
||||
* showing TO/FROM/subject/date so we can see what the dev mailbox is
|
||||
* actually receiving.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-imap-probe.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.env.IMAP_HOST!;
|
||||
const port = Number(process.env.IMAP_PORT ?? 993);
|
||||
const user = process.env.IMAP_USER!;
|
||||
const pass = process.env.IMAP_PASS!;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${user}@${host}:${port}…`);
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
console.log('Connected. Inbox status:');
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true, recent: true });
|
||||
console.log(' total:', status.messages, '| recent:', status.recent);
|
||||
|
||||
// Pull the last 10 by UID
|
||||
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
|
||||
const result = await client.search({ since });
|
||||
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
|
||||
console.log(`Found ${uids.length} messages in last 30min:`);
|
||||
for (const uid of uids) {
|
||||
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
|
||||
if (!msg || !msg.source) continue;
|
||||
const parsed = await simpleParser(msg.source);
|
||||
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
|
||||
.flatMap((a) => a.value.map((v) => v.address ?? ''))
|
||||
.join(', ');
|
||||
console.log(
|
||||
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
scripts/dev-list-users.ts
Normal file
25
scripts/dev-list-users.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
async function main() {
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
const users =
|
||||
await sql`SELECT id, email, name, email_verified, created_at FROM "user" ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user ---');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
const profiles =
|
||||
await sql`SELECT user_id, display_name, is_super_admin, is_active FROM user_profiles ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user_profiles ---');
|
||||
console.log(JSON.stringify(profiles, null, 2));
|
||||
const accounts =
|
||||
await sql`SELECT user_id, provider_id, account_id FROM account ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- account ---');
|
||||
console.log(JSON.stringify(accounts, null, 2));
|
||||
await sql.end();
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
83
scripts/dev-open-browser.ts
Normal file
83
scripts/dev-open-browser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Launch a headed Chromium with NO viewport override so it adopts the
|
||||
* host monitor's natural size — useful when you want to drive the CRM
|
||||
* manually and have full-screen real estate.
|
||||
*
|
||||
* Pre-fills the login form for the synthetic admin (admin@portnimara.test
|
||||
* / SuperAdmin12345!) but does not submit; press Enter when ready.
|
||||
*
|
||||
* The script keeps running until the browser window is closed by the
|
||||
* user or until you Ctrl-C.
|
||||
*
|
||||
* pnpm tsx scripts/dev-open-browser.ts # super_admin
|
||||
* pnpm tsx scripts/dev-open-browser.ts sales_agent
|
||||
* pnpm tsx scripts/dev-open-browser.ts viewer
|
||||
* pnpm tsx scripts/dev-open-browser.ts --no-prefill
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
// @playwright/test re-exports the same chromium driver and is already
|
||||
// installed as a dev dep; using it avoids needing to add the standalone
|
||||
// `playwright` package as a separate dependency.
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const USERS: Record<string, { email: string; password: string }> = {
|
||||
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
||||
sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' },
|
||||
viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' },
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const noPrefill = args.includes('--no-prefill');
|
||||
const role =
|
||||
args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!]
|
||||
? args.find((a) => !a.startsWith('--'))!
|
||||
: 'super_admin';
|
||||
const user = USERS[role]!;
|
||||
|
||||
console.log(`Launching headed Chromium → ${BASE_URL}`);
|
||||
console.log(` role: ${role} (${user.email})`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--start-maximized'],
|
||||
});
|
||||
|
||||
// viewport: null lets the page fill the OS window. Combined with
|
||||
// --start-maximized this matches the host monitor's natural size.
|
||||
const context = await browser.newContext({ viewport: null });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
|
||||
if (!noPrefill) {
|
||||
try {
|
||||
await page.waitForSelector('#email', { timeout: 5000 });
|
||||
await page.fill('#email', user.email);
|
||||
await page.fill('#password', user.password);
|
||||
console.log(' Login form pre-filled — press Enter in the browser to submit.');
|
||||
} catch {
|
||||
console.log(' Could not find login form (page may have redirected).');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log("Browser is open. Close it when you're done; the script will exit.");
|
||||
console.log('Or Ctrl-C here to force-quit.');
|
||||
|
||||
// Keep the process alive until the browser window is closed.
|
||||
await new Promise<void>((resolve) => {
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
await browser.close().catch(() => undefined);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Open-browser failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
52
scripts/dev-recommender-smoke.ts
Normal file
52
scripts/dev-recommender-smoke.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Dev-only smoke check for the berth recommender. Resolves the first
|
||||
* port-nimara interest (with desired dims set) and prints the top-N
|
||||
* recommendations.
|
||||
*
|
||||
* pnpm tsx scripts/dev-recommender-smoke.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, isNotNull, and } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, 'port-nimara'))
|
||||
.limit(1);
|
||||
if (!port) throw new Error('port-nimara not found');
|
||||
|
||||
const [interest] = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt)))
|
||||
.limit(1);
|
||||
if (!interest) throw new Error('No interest with desired dims set');
|
||||
|
||||
console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}…`);
|
||||
const recs = await recommendBerths({
|
||||
interestId: interest.id,
|
||||
portId: port.id,
|
||||
});
|
||||
|
||||
console.log(`> ${recs.length} recommendations:`);
|
||||
for (const r of recs) {
|
||||
console.log(
|
||||
` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` +
|
||||
`${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` +
|
||||
`${r.reasons.dimensional}; ${r.reasons.pipeline}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
28
scripts/dev-reset-admin-pw.ts
Normal file
28
scripts/dev-reset-admin-pw.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'dotenv/config';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { user, account } from '@/lib/db/schema/users';
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? 'admin@portnimara.test';
|
||||
const pw = process.argv[3] ?? 'SuperAdmin12345!';
|
||||
const [u] = await db.select().from(user).where(eq(user.email, email)).limit(1);
|
||||
if (!u) throw new Error(`user not found: ${email}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ctx = await (auth as any).$context;
|
||||
const hash = await ctx.password.hash(pw);
|
||||
const res = await db
|
||||
.update(account)
|
||||
.set({ password: hash })
|
||||
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||
.returning({ id: account.id });
|
||||
console.log(`updated ${res.length} credential row(s) for ${email}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
40
scripts/dev-set-password.ts
Normal file
40
scripts/dev-set-password.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Dev helper: set a user's password directly (bypasses email reset).
|
||||
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { hashPassword } from 'better-auth/crypto';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { user, account } from '@/lib/db/schema/users';
|
||||
|
||||
async function main() {
|
||||
const [, , email, password] = process.argv;
|
||||
if (!email || !password) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
|
||||
if (!u) {
|
||||
console.error(`User not found: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
const result = await db
|
||||
.update(account)
|
||||
.set({ password: hash, updatedAt: new Date() })
|
||||
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||
.returning({ id: account.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
console.error(`No credential account row for ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
44
scripts/dev-trigger-crm-invite.ts
Normal file
44
scripts/dev-trigger-crm-invite.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Dev-only helper: issue a CRM admin invite and send the activation email.
|
||||
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
|
||||
* lands in the dev inbox.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const email = args[0];
|
||||
if (!email) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
|
||||
process.exit(1);
|
||||
}
|
||||
const isSuperAdmin = args.includes('--super');
|
||||
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
|
||||
|
||||
// Dev script runs out-of-band (no HTTP request, no session). The service's
|
||||
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
|
||||
// invites; the script bypasses that with a synthetic caller identity.
|
||||
const { inviteId, link } = await createCrmInvite({
|
||||
email,
|
||||
name,
|
||||
isSuperAdmin,
|
||||
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
|
||||
});
|
||||
console.log(`✓ Invite created (id=${inviteId})`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` super_admin: ${isSuperAdmin}`);
|
||||
console.log(` activation link: ${link}`);
|
||||
console.log('');
|
||||
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
59
scripts/dev-trigger-portal-invite.ts
Normal file
59
scripts/dev-trigger-portal-invite.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Dev-only helper: pick an existing client and trigger a portal-invite email.
|
||||
* The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless
|
||||
* of the per-portal-user `email` field — so we can use any throwaway address
|
||||
* here without conflicting with seed data.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-portal-invite.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
import { createPortalUser } from '@/lib/services/portal-auth.service';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!env.EMAIL_REDIRECT_TO) {
|
||||
throw new Error(
|
||||
'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.',
|
||||
);
|
||||
}
|
||||
console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`);
|
||||
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'),
|
||||
});
|
||||
if (!client) throw new Error('No client found in port-nimara');
|
||||
|
||||
// Use the redirect target as the portal user's actual email, so the
|
||||
// tester can sign in with the same address that received the activation mail.
|
||||
const portalEmail = env.EMAIL_REDIRECT_TO;
|
||||
console.log(
|
||||
`Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}…`,
|
||||
);
|
||||
|
||||
// Clear any prior dev-script seed so uniqueness checks don't trip.
|
||||
await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id));
|
||||
await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail));
|
||||
|
||||
const result = await createPortalUser({
|
||||
clientId: client.id,
|
||||
portId: client.portId,
|
||||
email: portalEmail,
|
||||
name: client.fullName,
|
||||
createdBy: 'dev-script',
|
||||
});
|
||||
|
||||
console.log('Portal user created:', result);
|
||||
console.log(`Activation email enqueued — should arrive at ${portalEmail}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Script failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
138
scripts/encrypt-plaintext-credentials.ts
Normal file
138
scripts/encrypt-plaintext-credentials.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* One-time migration: encrypt any plaintext credential rows in
|
||||
* `system_settings` that should now be AES-256-GCM encrypted per the
|
||||
* settings registry. Safe to re-run (idempotent — only touches plaintext
|
||||
* rows, skips rows that are already encrypted envelopes).
|
||||
*
|
||||
* Currently handles:
|
||||
* - `documenso_api_key_override` → in-place encrypt
|
||||
* - `storage_s3_access_key` (legacy) → encrypt + move to
|
||||
* `storage_s3_access_key_encrypted`
|
||||
* - `documenso_webhook_secret` (if string) → in-place encrypt
|
||||
*
|
||||
* Run: `pnpm tsx scripts/encrypt-plaintext-credentials.ts`
|
||||
*/
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema';
|
||||
import { encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
const KEYS_TO_ENCRYPT_IN_PLACE = ['documenso_api_key_override', 'documenso_webhook_secret'];
|
||||
|
||||
function isEncryptedEnvelope(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { iv?: unknown }).iv === 'string' &&
|
||||
typeof (value as { tag?: unknown }).tag === 'string' &&
|
||||
typeof (value as { data?: unknown }).data === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptInPlace(key: string): Promise<{ touched: number; skipped: number }> {
|
||||
const rows = await db
|
||||
.select({ key: systemSettings.key, portId: systemSettings.portId, value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, key));
|
||||
|
||||
let touched = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
if (isEncryptedEnvelope(row.value)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (typeof row.value !== 'string' || row.value === '') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const envelope = JSON.parse(encrypt(row.value)) as {
|
||||
iv: string;
|
||||
tag: string;
|
||||
data: string;
|
||||
};
|
||||
if (row.portId) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ value: envelope, updatedAt: new Date() })
|
||||
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, row.portId)));
|
||||
} else {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ value: envelope, updatedAt: new Date() })
|
||||
.where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
|
||||
}
|
||||
touched++;
|
||||
}
|
||||
return { touched, skipped };
|
||||
}
|
||||
|
||||
async function moveS3AccessKeyToEncrypted(): Promise<{
|
||||
moved: number;
|
||||
alreadyMigrated: number;
|
||||
}> {
|
||||
// Move global rows only — s3 storage settings are global by design.
|
||||
const legacyRows = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
|
||||
|
||||
if (legacyRows.length === 0) {
|
||||
return { moved: 0, alreadyMigrated: 0 };
|
||||
}
|
||||
|
||||
// Check if the encrypted form already exists.
|
||||
const existingEncrypted = await db
|
||||
.select({ key: systemSettings.key })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(eq(systemSettings.key, 'storage_s3_access_key_encrypted'), isNull(systemSettings.portId)),
|
||||
);
|
||||
|
||||
if (existingEncrypted.length > 0) {
|
||||
// Encrypted form wins; leave the legacy row in place so reads still
|
||||
// tolerate it (the storage layer reads both and prefers encrypted).
|
||||
return { moved: 0, alreadyMigrated: legacyRows.length };
|
||||
}
|
||||
|
||||
const plaintext = legacyRows[0]!.value;
|
||||
if (typeof plaintext !== 'string' || plaintext === '') {
|
||||
return { moved: 0, alreadyMigrated: 0 };
|
||||
}
|
||||
const envelope = JSON.parse(encrypt(plaintext)) as { iv: string; tag: string; data: string };
|
||||
await db.insert(systemSettings).values({
|
||||
key: 'storage_s3_access_key_encrypted',
|
||||
portId: null,
|
||||
value: envelope,
|
||||
});
|
||||
// Drop the legacy plaintext row so it doesn't show up in admin
|
||||
// settings dumps anymore. The storage layer's backward-compat path
|
||||
// continues to handle older rows on other deployments.
|
||||
await db
|
||||
.delete(systemSettings)
|
||||
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
|
||||
return { moved: 1, alreadyMigrated: 0 };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Encrypting plaintext credentials...');
|
||||
|
||||
for (const key of KEYS_TO_ENCRYPT_IN_PLACE) {
|
||||
const { touched, skipped } = await encryptInPlace(key);
|
||||
console.log(` ${key}: ${touched} encrypted, ${skipped} skipped`);
|
||||
}
|
||||
|
||||
const s3 = await moveS3AccessKeyToEncrypted();
|
||||
console.log(
|
||||
` storage_s3_access_key → _encrypted: ${s3.moved} moved, ${s3.alreadyMigrated} already migrated`,
|
||||
);
|
||||
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
409
scripts/import-berths-from-nocodb.ts
Normal file
409
scripts/import-berths-from-nocodb.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Idempotent NocoDB Berths → CRM `berths` import.
|
||||
*
|
||||
* Re-running picks up NocoDB additions/edits without clobbering CRM-side
|
||||
* overrides: rows where `updated_at > last_imported_at` are treated as
|
||||
* human-edited and skipped (use `--force` to override). Map Data JSON
|
||||
* is validated and upserted into `berth_map_data` as a separate step.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug port-nimara]
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug port-nimara]
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot
|
||||
*
|
||||
* Edge cases mitigated (see plan §14.1):
|
||||
* - Mooring collisions : unique (port_id, mooring_number) on the table.
|
||||
* - Concurrent runs : pg_advisory_xact_lock on a stable key.
|
||||
* - Numeric-with-units : parseDecimalWithUnit() strips trailing units.
|
||||
* - Metric drift : NocoDB metric formula columns are ignored;
|
||||
* metric values are recomputed from imperial.
|
||||
* - Map Data shape : zod-validated; failures are skipped silently
|
||||
* rather than aborting the whole import.
|
||||
* - Status enum : NocoDB display strings → CRM snake_case.
|
||||
* - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||
import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source';
|
||||
import {
|
||||
buildPlan,
|
||||
mapRow,
|
||||
type Action,
|
||||
type ImportedBerth,
|
||||
type PlanEntry,
|
||||
type ExistingBerthRow,
|
||||
} from '@/lib/services/berth-import';
|
||||
|
||||
// ─── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string;
|
||||
force: boolean;
|
||||
updateSnapshot: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: 'port-nimara',
|
||||
force: false,
|
||||
updateSnapshot: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara';
|
||||
else if (a === '--force') args.force = true;
|
||||
else if (a === '--update-snapshot') args.updateSnapshot = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify either --dry-run or --apply.');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug <slug>] [--force] [--update-snapshot]
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB + diff vs CRM. No writes.
|
||||
--apply Apply the plan to the DB.
|
||||
--port-slug <slug> Target port slug (default: port-nimara).
|
||||
--force Overwrite rows where CRM updated_at > last_imported_at.
|
||||
--update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Stable advisory lock key ───────────────────────────────────────────────
|
||||
// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks.
|
||||
const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n;
|
||||
|
||||
// ─── Apply ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ApplyResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
mapDataWritten: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function apply(
|
||||
portId: string,
|
||||
plan: PlanEntry[],
|
||||
orphans: ExistingBerthRow[],
|
||||
importedAt: Date,
|
||||
): Promise<ApplyResult> {
|
||||
const result: ApplyResult = {
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
mapDataWritten: 0,
|
||||
warnings: [],
|
||||
};
|
||||
for (const orphan of orphans) {
|
||||
result.warnings.push(
|
||||
`Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Stable lock so two simultaneous --apply runs serialize.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`);
|
||||
|
||||
for (const entry of plan) {
|
||||
if (entry.action === 'skip-edited' || entry.action === 'noop') {
|
||||
result.skipped += 1;
|
||||
result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`);
|
||||
continue;
|
||||
}
|
||||
const i = entry.imported;
|
||||
const n = i.numerics;
|
||||
const baseValues = {
|
||||
portId,
|
||||
mooringNumber: i.mooringNumber,
|
||||
area: i.area,
|
||||
status: i.status,
|
||||
lengthFt: n.lengthFt != null ? String(n.lengthFt) : null,
|
||||
widthFt: n.widthFt != null ? String(n.widthFt) : null,
|
||||
draftFt: n.draftFt != null ? String(n.draftFt) : null,
|
||||
lengthM: n.lengthM != null ? String(n.lengthM) : null,
|
||||
widthM: n.widthM != null ? String(n.widthM) : null,
|
||||
draftM: n.draftM != null ? String(n.draftM) : null,
|
||||
widthIsMinimum: i.widthIsMinimum,
|
||||
nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null,
|
||||
waterDepth: n.waterDepth != null ? String(n.waterDepth) : null,
|
||||
waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null,
|
||||
waterDepthIsMinimum: i.waterDepthIsMinimum,
|
||||
sidePontoon: i.sidePontoon,
|
||||
powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null,
|
||||
voltage: n.voltage != null ? String(n.voltage) : null,
|
||||
mooringType: i.mooringType,
|
||||
cleatType: i.cleatType,
|
||||
cleatCapacity: i.cleatCapacity,
|
||||
bollardType: i.bollardType,
|
||||
bollardCapacity: i.bollardCapacity,
|
||||
access: i.access,
|
||||
price: n.price != null ? String(n.price) : null,
|
||||
priceCurrency: 'USD' as const,
|
||||
bowFacing: i.bowFacing,
|
||||
berthApproved: i.berthApproved,
|
||||
statusOverrideMode: i.statusOverrideMode,
|
||||
lastImportedAt: importedAt,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
|
||||
let berthId: string;
|
||||
if (entry.action === 'insert') {
|
||||
const [inserted] = await tx
|
||||
.insert(berths)
|
||||
.values({ ...baseValues, tenureType: 'permanent' })
|
||||
.returning({ id: berths.id });
|
||||
berthId = inserted!.id;
|
||||
result.inserted += 1;
|
||||
} else {
|
||||
await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id));
|
||||
berthId = entry.existing!.id;
|
||||
result.updated += 1;
|
||||
}
|
||||
|
||||
if (i.mapData) {
|
||||
const mapValues = {
|
||||
berthId,
|
||||
svgPath: i.mapData.path ?? null,
|
||||
x: i.mapData.x != null ? String(i.mapData.x) : null,
|
||||
y: i.mapData.y != null ? String(i.mapData.y) : null,
|
||||
transform: i.mapData.transform ?? null,
|
||||
fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
await tx
|
||||
.insert(berthMapData)
|
||||
.values(mapValues)
|
||||
.onConflictDoUpdate({
|
||||
target: berthMapData.berthId,
|
||||
set: {
|
||||
svgPath: mapValues.svgPath,
|
||||
x: mapValues.x,
|
||||
y: mapValues.y,
|
||||
transform: mapValues.transform,
|
||||
fontSize: mapValues.fontSize,
|
||||
updatedAt: importedAt,
|
||||
},
|
||||
});
|
||||
result.mapDataWritten += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Snapshot writer (for seed-data refresh) ────────────────────────────────
|
||||
|
||||
async function writeSnapshot(imported: ImportedBerth[]): Promise<string> {
|
||||
// Ordering: idx 0..4 available (small), 5..9 under_offer (medium),
|
||||
// 10..11 sold (large), then everything else by mooring number. The
|
||||
// first 12 indexes feed `seed-data.ts` interest/reservation stubs.
|
||||
const sortByLength = (a: ImportedBerth, b: ImportedBerth) =>
|
||||
(a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0);
|
||||
const available = imported
|
||||
.filter((b) => b.status === 'available')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const underOffer = imported
|
||||
.filter((b) => b.status === 'under_offer')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const sold = imported
|
||||
.filter((b) => b.status === 'sold')
|
||||
.sort((a, b) => -sortByLength(a, b))
|
||||
.slice(0, 2);
|
||||
const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber));
|
||||
const rest = imported
|
||||
.filter((b) => !featured.has(b.mooringNumber))
|
||||
.sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true }));
|
||||
const ordered = [...available, ...underOffer, ...sold, ...rest];
|
||||
|
||||
const payload = ordered.map((b) => ({
|
||||
legacyId: b.legacyId,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.numerics.lengthFt,
|
||||
widthFt: b.numerics.widthFt,
|
||||
draftFt: b.numerics.draftFt,
|
||||
lengthM: b.numerics.lengthM,
|
||||
widthM: b.numerics.widthM,
|
||||
draftM: b.numerics.draftM,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.numerics.nominalBoatSize,
|
||||
nominalBoatSizeM: b.numerics.nominalBoatSizeM,
|
||||
waterDepth: b.numerics.waterDepth,
|
||||
waterDepthM: b.numerics.waterDepthM,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.numerics.powerCapacity,
|
||||
voltage: b.numerics.voltage,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.numerics.price,
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
}));
|
||||
|
||||
const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json');
|
||||
await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
return target;
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const config = loadNocoDbConfig();
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`> Fetching NocoDB Berths…`);
|
||||
const rows = await fetchAllRows(NOCO_TABLES.berths, config);
|
||||
console.log(` fetched ${rows.length} rows from NocoDB`);
|
||||
|
||||
const imported: ImportedBerth[] = [];
|
||||
let skippedMalformed = 0;
|
||||
for (const r of rows) {
|
||||
const m = mapRow(r);
|
||||
if (m) imported.push(m);
|
||||
else skippedMalformed += 1;
|
||||
}
|
||||
if (skippedMalformed > 0) {
|
||||
console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`);
|
||||
}
|
||||
|
||||
// De-dup against any same-mooring twins surfacing from NocoDB
|
||||
// (defensive — the Berths table is keyed on Mooring Number in NocoDB).
|
||||
const seen = new Set<string>();
|
||||
const dedup: ImportedBerth[] = [];
|
||||
for (const b of imported) {
|
||||
if (seen.has(b.mooringNumber)) {
|
||||
console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`);
|
||||
continue;
|
||||
}
|
||||
seen.add(b.mooringNumber);
|
||||
dedup.push(b);
|
||||
}
|
||||
|
||||
console.log(`> Reading current CRM berths for port "${port.slug}"…`);
|
||||
const existingRows = await db
|
||||
.select({
|
||||
id: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
updatedAt: berths.updatedAt,
|
||||
lastImportedAt: berths.lastImportedAt,
|
||||
})
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
console.log(` ${existingRows.length} existing rows`);
|
||||
|
||||
const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r]));
|
||||
const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force);
|
||||
|
||||
const counts = plan.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.action] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record<Action, number>,
|
||||
);
|
||||
|
||||
console.log(`> Plan:`);
|
||||
console.log(` insert : ${counts.insert}`);
|
||||
console.log(` update : ${counts.update}`);
|
||||
console.log(` skip-edited : ${counts['skip-edited']}`);
|
||||
console.log(` no-op : ${counts.noop}`);
|
||||
console.log(` orphans (CRM): ${orphans.length}`);
|
||||
|
||||
if (counts['skip-edited'] > 0) {
|
||||
console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`);
|
||||
for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) {
|
||||
console.log(` - ${e.imported.mooringNumber} ${e.reason}`);
|
||||
}
|
||||
if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
console.log(` ↳ Orphans (in CRM but missing from NocoDB):`);
|
||||
for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`);
|
||||
if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`);
|
||||
}
|
||||
|
||||
// Snapshot write is independent of DB writes — even in --dry-run mode
|
||||
// a rep may want to refresh the seed JSON to capture the latest NocoDB
|
||||
// shape without committing to the DB import. The original gate dropped
|
||||
// this silently when --dry-run was passed; audit caught it.
|
||||
if (args.updateSnapshot) {
|
||||
const written = await writeSnapshot(dedup);
|
||||
console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`);
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`\n[dry-run] no DB writes performed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`> Applying…`);
|
||||
const result = await apply(port.id, plan, orphans, new Date());
|
||||
console.log(` inserted : ${result.inserted}`);
|
||||
console.log(` updated : ${result.updated}`);
|
||||
console.log(` skipped : ${result.skipped}`);
|
||||
console.log(` map data writes : ${result.mapDataWritten}`);
|
||||
if (result.warnings.length) {
|
||||
console.log(` warnings :`);
|
||||
for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`);
|
||||
if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
326
scripts/import-organized-documents.ts
Normal file
326
scripts/import-organized-documents.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Importer for an organized S3 / filesystem bucket whose folder structure
|
||||
* already represents real organisation. Walks every key under `--bucket-prefix`,
|
||||
* builds matching `document_folders` rows mirroring the path, then inserts
|
||||
* `documents` + `files` rows pointing at the existing storage keys verbatim
|
||||
* — no path rewrite. Use when migrating from a legacy MinIO bucket whose
|
||||
* tree is the source of truth.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/import-organized-documents.ts --port-slug <slug> \
|
||||
* --bucket-prefix "legacy-imports/" --dry-run
|
||||
* pnpm tsx scripts/import-organized-documents.ts --port-slug <slug> \
|
||||
* --bucket-prefix "legacy-imports/" --apply
|
||||
*
|
||||
* Idempotency:
|
||||
* - Folders: sibling-name unique index swallows duplicate creates and we
|
||||
* reuse the existing row.
|
||||
* - Documents: skipped when a row with `(port_id, fileStoragePath)` already
|
||||
* exists — the storage key is the natural identity for this importer.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { documents, documentFolders, files } from '@/lib/db/schema/documents';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import { createFolder } from '@/lib/services/document-folders.service';
|
||||
import { parseImportPath } from '@/lib/services/document-import';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string;
|
||||
bucketPrefix: string;
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
uploadedByUserId: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
portSlug: '',
|
||||
bucketPrefix: '',
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
uploadedByUserId: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? '';
|
||||
else if (a === '--bucket-prefix') args.bucketPrefix = argv[++i] ?? '';
|
||||
else if (a === '--uploaded-by') args.uploadedByUserId = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify either --dry-run or --apply.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.dryRun && args.apply) {
|
||||
console.error('--dry-run and --apply are mutually exclusive.');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/import-organized-documents.ts \\
|
||||
--port-slug <slug> \\
|
||||
--bucket-prefix <prefix> \\
|
||||
(--dry-run | --apply) \\
|
||||
[--uploaded-by <userId>]
|
||||
`);
|
||||
}
|
||||
|
||||
interface PlannedDoc {
|
||||
key: string;
|
||||
folderSegments: string[];
|
||||
filename: string;
|
||||
bytes: number | null;
|
||||
contentType: string;
|
||||
alreadyImported: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_BY_EXT: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
function guessContentType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return CONTENT_TYPE_BY_EXT[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, args.portSlug) });
|
||||
if (!port) {
|
||||
console.error(`Port not found: ${args.portSlug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let uploadedById = args.uploadedByUserId;
|
||||
if (!uploadedById) {
|
||||
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||
if (!u) {
|
||||
console.error(
|
||||
'No user rows exist; pass --uploaded-by <userId> or seed at least one user before running.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
uploadedById = u.id;
|
||||
console.log(`No --uploaded-by provided; falling back to first user: ${uploadedById}`);
|
||||
}
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
console.log(`Listing keys under prefix "${args.bucketPrefix}" via ${backend.name} backend …`);
|
||||
const keys = await backend.listByPrefix(args.bucketPrefix);
|
||||
console.log(`Found ${keys.length} candidate keys.`);
|
||||
|
||||
const plan: PlannedDoc[] = [];
|
||||
for (const key of keys) {
|
||||
const parsed = parseImportPath(args.bucketPrefix, key);
|
||||
if (!parsed.filename) continue;
|
||||
|
||||
const head = await backend.head(key);
|
||||
const existing = await db.query.files.findFirst({
|
||||
where: and(eq(files.portId, port.id), eq(files.storagePath, key)),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
plan.push({
|
||||
key,
|
||||
folderSegments: parsed.folderSegments,
|
||||
filename: parsed.filename,
|
||||
bytes: head?.sizeBytes ?? null,
|
||||
contentType: head?.contentType ?? guessContentType(parsed.filename),
|
||||
alreadyImported: !!existing,
|
||||
});
|
||||
}
|
||||
|
||||
printPlan(plan);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\nDry-run complete. No changes written.');
|
||||
return;
|
||||
}
|
||||
|
||||
const folderIdByPath = new Map<string, string | null>();
|
||||
folderIdByPath.set('', null);
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const entry of plan) {
|
||||
if (entry.alreadyImported) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderId = await ensureFolderChain(
|
||||
port.id,
|
||||
uploadedById,
|
||||
entry.folderSegments,
|
||||
folderIdByPath,
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [fileRow] = await tx
|
||||
.insert(files)
|
||||
.values({
|
||||
portId: port.id,
|
||||
filename: entry.filename,
|
||||
originalName: entry.filename,
|
||||
mimeType: entry.contentType,
|
||||
sizeBytes: entry.bytes !== null ? String(entry.bytes) : null,
|
||||
storagePath: entry.key,
|
||||
uploadedBy: uploadedById,
|
||||
category: 'misc',
|
||||
folderId,
|
||||
})
|
||||
.returning();
|
||||
const [docRow] = await tx
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId: port.id,
|
||||
documentType: 'other',
|
||||
title: entry.filename,
|
||||
createdBy: uploadedById,
|
||||
folderId,
|
||||
fileId: fileRow!.id,
|
||||
status: 'completed',
|
||||
isManualUpload: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: uploadedById,
|
||||
portId: port.id,
|
||||
action: 'create',
|
||||
entityType: 'document',
|
||||
entityId: docRow!.id,
|
||||
metadata: {
|
||||
source: 'organized-bucket-importer',
|
||||
storageKey: entry.key,
|
||||
folderSegments: entry.folderSegments,
|
||||
},
|
||||
});
|
||||
});
|
||||
createdCount += 1;
|
||||
console.log(`✓ Imported ${entry.key}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. Created ${createdCount} documents, skipped ${skippedCount} (already imported).`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureFolderChain(
|
||||
portId: string,
|
||||
userId: string,
|
||||
segments: string[],
|
||||
cache: Map<string, string | null>,
|
||||
): Promise<string | null> {
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
let parentId: string | null = null;
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const pathKey = segments.slice(0, i + 1).join('/');
|
||||
const cached = cache.get(pathKey);
|
||||
if (cached !== undefined) {
|
||||
parentId = cached;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = segments[i]!;
|
||||
parentId = await createOrFindFolder(portId, userId, name, parentId);
|
||||
cache.set(pathKey, parentId);
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
async function createOrFindFolder(
|
||||
portId: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const created = await createFolder(portId, userId, { name, parentId });
|
||||
return created.id;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ConflictError)) throw err;
|
||||
// Sibling-name unique index hit — fetch the existing row so the import
|
||||
// remains idempotent across re-runs.
|
||||
const trimmed = name.trim();
|
||||
const candidates = await db.query.documentFolders.findMany({
|
||||
where: parentId
|
||||
? and(eq(documentFolders.portId, portId), eq(documentFolders.parentId, parentId))
|
||||
: eq(documentFolders.portId, portId),
|
||||
});
|
||||
const existing = candidates.find(
|
||||
(row) =>
|
||||
(parentId ? row.parentId === parentId : row.parentId === null) &&
|
||||
row.name.toLowerCase() === trimmed.toLowerCase(),
|
||||
);
|
||||
if (!existing) throw err;
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
function printPlan(plan: PlannedDoc[]): void {
|
||||
const grouped = new Map<string, PlannedDoc[]>();
|
||||
for (const entry of plan) {
|
||||
const folder = entry.folderSegments.join('/') || '(root)';
|
||||
if (!grouped.has(folder)) grouped.set(folder, []);
|
||||
grouped.get(folder)!.push(entry);
|
||||
}
|
||||
const folderNames = Array.from(grouped.keys()).sort();
|
||||
console.log('\nPlan:');
|
||||
for (const folder of folderNames) {
|
||||
console.log(` ${folder}/`);
|
||||
for (const entry of grouped.get(folder)!) {
|
||||
const flag = entry.alreadyImported ? '·' : '+';
|
||||
const size = entry.bytes !== null ? ` (${entry.bytes}B)` : '';
|
||||
console.log(` ${flag} ${entry.filename}${size}`);
|
||||
}
|
||||
}
|
||||
const newCount = plan.filter((p) => !p.alreadyImported).length;
|
||||
const dupCount = plan.length - newCount;
|
||||
console.log(`\nTotal: ${plan.length} keys → ${newCount} new, ${dupCount} already imported.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
277
scripts/migrate-from-nocodb.ts
Normal file
277
scripts/migrate-from-nocodb.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* One-shot migration: legacy NocoDB Interests → new client/interest split.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||
* Same, but tags the planned writes with the named port (matters for
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||
* Re-fetches NocoDB, re-transforms, then writes the planned rows
|
||||
* into the target port via the idempotent `migration_source_links`
|
||||
* ledger. Re-runs are safe — already-imported source IDs are skipped.
|
||||
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
|
||||
* `--unsafe-skip-redirect-check` is also passed.
|
||||
*
|
||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
|
||||
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
unsafeSkipRedirectCheck: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
unsafeSkipRedirectCheck: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||
Re-fetches NocoDB, re-transforms, writes via migration_source_links
|
||||
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
|
||||
(unless --unsafe-skip-redirect-check is also passed).
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write rows to the DB.
|
||||
--port-slug <slug> Port slug to attach to all imported
|
||||
entities. Defaults to the first
|
||||
available port if omitted.
|
||||
--report <dir> Path to a previously-generated report
|
||||
dir (only used by --apply).
|
||||
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
|
||||
check. Only use in production cutover.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target port: use the slug if provided, otherwise the first
|
||||
* port found. Errors out cleanly if the slug doesn't match any port.
|
||||
*/
|
||||
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
|
||||
if (slug) {
|
||||
const [p] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slug))
|
||||
.limit(1);
|
||||
if (!p) {
|
||||
console.error(`No port found with slug "${slug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: p.id, slug: p.slug };
|
||||
}
|
||||
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
|
||||
if (!first) {
|
||||
console.error('No ports exist in the target DB. Seed at least one port before applying.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: first.id, slug: first.slug };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify --dry-run or --apply');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||
// operator explicitly opts out (production cutover).
|
||||
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
|
||||
console.error(
|
||||
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
|
||||
);
|
||||
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
|
||||
console.error(
|
||||
'If you are running the production cutover and have read that doc, add ' +
|
||||
'--unsafe-skip-redirect-check to override.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
console.log(`[migrate] Source: ${config.url}`);
|
||||
|
||||
console.log('[migrate] Fetching snapshot from NocoDB…');
|
||||
const start = Date.now();
|
||||
const snapshot = await fetchSnapshot(config);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths, ${snapshot.expenses?.length ?? 0} expenses.`,
|
||||
);
|
||||
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root.
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
const paths = resolveReportPaths(repoRoot);
|
||||
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
console.log('=== Migration Plan Summary ===');
|
||||
console.log(
|
||||
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
|
||||
);
|
||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||
console.log(
|
||||
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
|
||||
);
|
||||
console.log(
|
||||
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||
);
|
||||
console.log(` ${s.outputExpenses} expenses`);
|
||||
console.log(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
console.log(` Full report: ${paths.summaryPath}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('');
|
||||
console.log('Dry-run complete. Re-run with --apply to write rows.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Apply path ───────────────────────────────────────────────────────────
|
||||
|
||||
const port = await resolvePort(args.portSlug);
|
||||
const applyId = randomUUID();
|
||||
|
||||
console.log('');
|
||||
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
|
||||
console.log(`[migrate] Apply id: ${applyId}`);
|
||||
console.log('[migrate] Inserting…');
|
||||
|
||||
const applyStart = Date.now();
|
||||
const result = await applyPlan(plan, { port, applyId, appliedBy: SUPER_ADMIN_USER_ID });
|
||||
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||
|
||||
console.log('');
|
||||
console.log('=== Apply Result ===');
|
||||
console.log(` Time: ${applyElapsed}s`);
|
||||
console.log(
|
||||
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Contacts: ${result.contactsInserted} inserted`);
|
||||
console.log(` Addresses: ${result.addressesInserted} inserted`);
|
||||
console.log(` Yachts: ${result.yachtsInserted} inserted`);
|
||||
console.log(
|
||||
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||
);
|
||||
console.log(
|
||||
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Signers: ${result.documentSignersInserted} inserted`);
|
||||
console.log(
|
||||
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||
console.log(
|
||||
` Expenses: ${result.expensesInserted} inserted, ${result.expensesSkipped} already linked`,
|
||||
);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('');
|
||||
console.log('Warnings:');
|
||||
for (const w of result.warnings.slice(0, 20)) {
|
||||
console.log(` - ${w}`);
|
||||
}
|
||||
if (result.warnings.length > 20) {
|
||||
console.log(` … ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-berth links (folded in for the one-shot seed) ──────────────────
|
||||
// The dedup plan only carries each deal's single `Berth Number`; the legacy
|
||||
// `_nc_m2m_Berths_Interests` junction (multi-berth deals) is reconnected
|
||||
// here from the local `nocodb_legacy` snapshot. Best-effort: if the dump
|
||||
// isn't restored, log + continue (the standalone script can run it later).
|
||||
try {
|
||||
const { connectBerthLinks } = await import('./migration/connect-berth-links');
|
||||
const bl = await connectBerthLinks({ portSlug: port.slug });
|
||||
console.log(
|
||||
` Berths: ${bl.inserted} multi-berth links inserted (${bl.madePrimary} new primary), ${bl.skipped} already linked`,
|
||||
);
|
||||
if (bl.unresolved.length > 0) {
|
||||
console.log(` ⚠ ${bl.unresolved.length} moorings with no CRM berth`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
` Berths: ⚠ multi-berth link step skipped (${(err as Error).message}). ` +
|
||||
`Run scripts/migration/connect-berth-links.ts once the nocodb_legacy dump is restored.`,
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[migrate] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/migrate-storage.ts
Normal file
29
scripts/migrate-storage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Storage backend migration CLI — see §4.7a + §14.9a of
|
||||
* docs/berth-recommender-and-pdf-plan.md.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-storage.ts --from s3 --to filesystem [--dry-run]
|
||||
* pnpm tsx scripts/migrate-storage.ts --from filesystem --to s3
|
||||
*
|
||||
* The actual migration logic lives in `src/lib/storage/migrate.ts` so the
|
||||
* admin UI's "Switch backend" button can run the exact same code path. This
|
||||
* file is a thin CLI wrapper.
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { parseArgs, runMigration } from '@/lib/storage/migrate';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
logger.info({ args }, 'Starting storage migration');
|
||||
const result = await runMigration(args);
|
||||
logger.info({ result }, 'Storage migration complete');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Storage migration failed');
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
503
scripts/migration/backfill-documents.ts
Normal file
503
scripts/migration/backfill-documents.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Phase 2 of the legacy migration: pull signed EOI PDFs + berth spec PDFs from
|
||||
* the LEGACY MinIO (`client-portal` bucket) and deposit them into the CRM's own
|
||||
* storage, linking them to the already-migrated deals + berths.
|
||||
*
|
||||
* Two storage worlds, kept strictly separate:
|
||||
* - LEGACY read : a dedicated `minio` Client using LEGACY_MINIO_* env.
|
||||
* - CRM write : `getStorageBackend()` (the CRM's own configured storage).
|
||||
* ⚠ We NEVER route legacy creds through getStorageBackend — that would
|
||||
* write INTO prod. LEGACY_MINIO_* is distinct from the CRM's MINIO_*.
|
||||
*
|
||||
* Idempotent + re-runnable: an EOI is skipped once its `documents.signedFileId`
|
||||
* is set; a berth is skipped once it has a `currentPdfVersionId`.
|
||||
*
|
||||
* Run AFTER `migrate-from-nocodb.ts --apply`:
|
||||
* LEGACY_MINIO_ACCESS_KEY=… LEGACY_MINIO_SECRET_KEY=… \
|
||||
* pnpm tsx scripts/migration/backfill-documents.ts --port-slug port-nimara [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client as MinioClient } from 'minio';
|
||||
import postgres from 'postgres';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db, closeDb } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { documents, files } from '@/lib/db/schema/documents';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { buildStoragePath } from '@/lib/minio';
|
||||
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||||
import { uploadBerthPdf } from '@/lib/services/berth-pdf.service';
|
||||
import { normalizeName } from '@/lib/dedup/normalize';
|
||||
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
|
||||
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
const slugArg = (() => {
|
||||
const i = process.argv.indexOf('--port-slug');
|
||||
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||
})();
|
||||
|
||||
const LEGACY_BUCKET = process.env.LEGACY_MINIO_BUCKET ?? 'client-portal';
|
||||
// NocoDB's own attachment store — where pre-Documenso "LOI process" EOIs live.
|
||||
const DATABASE_BUCKET = process.env.LEGACY_MINIO_DATABASE_BUCKET ?? 'database';
|
||||
const legacy = new MinioClient({
|
||||
endPoint: process.env.LEGACY_MINIO_ENDPOINT ?? 's3.portnimara.com',
|
||||
port: 443,
|
||||
useSSL: true,
|
||||
accessKey: process.env.LEGACY_MINIO_ACCESS_KEY ?? '',
|
||||
secretKey: process.env.LEGACY_MINIO_SECRET_KEY ?? '',
|
||||
});
|
||||
|
||||
// Read-only connection to the LOCAL restored NocoDB dump (`nocodb_legacy`) —
|
||||
// used to read the `EOI_Document` attachment metadata. Never prod.
|
||||
const CRM_DB_URL = process.env.DATABASE_URL ?? '';
|
||||
const LEGACY_DB_URL = process.env.LEGACY_DB_URL ?? CRM_DB_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||
|
||||
/** Levenshtein edit distance — conservative fuzzy name matching for legacy
|
||||
* spelling/format drift (Koshbin↔Khoshbin, Costanzo↔Constanzo). */
|
||||
function lev(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
if (!m) return n;
|
||||
if (!n) return m;
|
||||
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
const cur = [i];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
cur[j] = Math.min(
|
||||
prev[j]! + 1,
|
||||
cur[j - 1]! + 1,
|
||||
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
return prev[n]!;
|
||||
}
|
||||
|
||||
function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (c: Buffer) => chunks.push(c));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
interface LegacyObject {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
function listLegacy(prefix: string): Promise<LegacyObject[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const out: LegacyObject[] = [];
|
||||
const stream = legacy.listObjectsV2(LEGACY_BUCKET, prefix, true);
|
||||
stream.on('data', (o) => {
|
||||
if (o.name && !o.name.endsWith('/')) out.push({ name: o.name, size: o.size ?? 0 });
|
||||
});
|
||||
stream.on('end', () => resolve(out));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePort(slug: string): Promise<{ id: string; slug: string }> {
|
||||
const [p] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slug))
|
||||
.limit(1);
|
||||
if (!p) throw new Error(`No port with slug "${slug}"`);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── Berth PDFs ──────────────────────────────────────────────────────────────
|
||||
// client-portal/Berth-PDFs/<ts>-Berth_Spec_Sheet_<Mooring>.pdf → berth by mooring.
|
||||
async function backfillBerthPdfs(port: { id: string; slug: string }) {
|
||||
const objs = (await listLegacy('Berth-PDFs/')).filter((o) => /\.pdf$/i.test(o.name));
|
||||
const berthRows = await db
|
||||
.select({ id: berths.id, mooring: berths.mooringNumber, cur: berths.currentPdfVersionId })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
const byMooring = new Map(berthRows.map((b) => [b.mooring, b]));
|
||||
|
||||
let attached = 0;
|
||||
let skipped = 0;
|
||||
let unmatched = 0;
|
||||
for (const o of objs) {
|
||||
const m = o.name.match(/Berth_Spec_Sheet_([A-Za-z]+\d+)\.pdf$/i);
|
||||
if (!m) {
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
const mooring = `${m[1]!.replace(/[a-z]+/g, (s) => s.toUpperCase())}`
|
||||
.toUpperCase()
|
||||
.replace(/([A-Z]+)0*(\d+)/, '$1$2');
|
||||
const berth = byMooring.get(mooring);
|
||||
if (!berth) {
|
||||
console.log(` [berth] no berth for mooring "${mooring}" (${o.name})`);
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
if (berth.cur) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
attached++;
|
||||
continue;
|
||||
}
|
||||
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, o.name));
|
||||
await uploadBerthPdf({
|
||||
berthId: berth.id,
|
||||
portId: port.id,
|
||||
buffer: buf,
|
||||
fileName: o.name.split('/').pop() ?? `${mooring}.pdf`,
|
||||
uploadedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
attached++;
|
||||
}
|
||||
return { total: objs.length, attached, skipped, unmatched };
|
||||
}
|
||||
|
||||
// ─── Signed EOIs ─────────────────────────────────────────────────────────────
|
||||
// client-portal/EOIs/<Client Name>/<file>.pdf → match by normalized client name.
|
||||
async function backfillEois(port: { id: string; slug: string }) {
|
||||
// Signed EOIs live under EOIs/<Name>/ and (some) under Client Documents/<Name>/.
|
||||
const objs = [...(await listLegacy('EOIs/')), ...(await listLegacy('Client Documents/'))].filter(
|
||||
(o) => /\.pdf$/i.test(o.name) && /eoi|sign/i.test(o.name),
|
||||
);
|
||||
// Index the best signed PDF per normalized folder (client) name.
|
||||
const byName = new Map<string, { key: string; size: number }>();
|
||||
for (const o of objs) {
|
||||
const parts = o.name.split('/'); // <prefix> / <Name> / <file>.pdf
|
||||
if (parts.length < 3) continue;
|
||||
const folder = (parts[1] ?? '').replace(/_/g, ' '); // "Matt_Ciaccio" → "Matt Ciaccio"
|
||||
const norm = normalizeName(folder).display;
|
||||
if (!norm) continue;
|
||||
const isSigned = /sign/i.test(o.name);
|
||||
const prev = byName.get(norm);
|
||||
// Prefer a "signed" file; among those, the largest (the full signed PDF).
|
||||
if (!prev || (isSigned && o.size > prev.size)) byName.set(norm, { key: o.name, size: o.size });
|
||||
}
|
||||
|
||||
// Migrated EOI documents missing a signed file.
|
||||
const docRows = await db
|
||||
.select({ id: documents.id, interestId: documents.interestId, clientId: documents.clientId })
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, port.id),
|
||||
eq(documents.documentType, 'eoi'),
|
||||
isNull(documents.signedFileId),
|
||||
),
|
||||
);
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
let attached = 0;
|
||||
let unmatched = 0;
|
||||
const unresolved: string[] = [];
|
||||
for (const doc of docRows) {
|
||||
const clientId = doc.clientId;
|
||||
if (!clientId) {
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
const [c] = await db
|
||||
.select({ name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
if (!c) {
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
const target = normalizeName(c.name).display;
|
||||
let match = byName.get(target);
|
||||
if (!match && target.length >= 6) {
|
||||
// Conservative fuzzy fallback: best edit-distance ≤ 2 on the full name.
|
||||
let bestDist = 3;
|
||||
for (const [name, v] of byName) {
|
||||
const d = lev(name, target);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
match = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!match) {
|
||||
unresolved.push(c.name);
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
attached++;
|
||||
continue;
|
||||
}
|
||||
// Pull legacy bytes → write to CRM storage → files row → link signedFileId.
|
||||
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, match.key));
|
||||
const key = buildStoragePath(port.slug, 'eoi-signed', doc.id, randomUUID(), 'pdf');
|
||||
const putRes = await backend.put(key, buf, {
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: buf.length,
|
||||
});
|
||||
// File into the client's entity folder (mirrors handleDocumentCompleted's
|
||||
// owner-folder filing). files.interestId still scopes the row to the deal;
|
||||
// interest "Deal" folders aren't system-managed (chk_system_folder_shape).
|
||||
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
|
||||
const fileName = match.key.split('/').pop() ?? 'eoi-signed.pdf';
|
||||
await db.transaction(async (tx) => {
|
||||
const [f] = await tx
|
||||
.insert(files)
|
||||
.values({
|
||||
portId: port.id,
|
||||
filename: fileName,
|
||||
originalName: fileName,
|
||||
storagePath: putRes.key,
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: String(putRes.sizeBytes),
|
||||
category: 'eoi',
|
||||
folderId: folder.id,
|
||||
clientId,
|
||||
interestId: doc.interestId,
|
||||
uploadedBy: 'system',
|
||||
})
|
||||
.returning({ id: files.id });
|
||||
if (!f) throw new Error('files insert returned no row');
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({ signedFileId: f.id, status: 'completed', isManualUpload: true })
|
||||
.where(eq(documents.id, doc.id));
|
||||
});
|
||||
attached++;
|
||||
}
|
||||
return {
|
||||
totalBlobs: objs.length,
|
||||
indexedClients: byName.size,
|
||||
candidates: docRows.length,
|
||||
attached,
|
||||
unmatched,
|
||||
unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Old-LOI EOIs (NocoDB `database` bucket attachments) ─────────────────────
|
||||
// The ~10 pre-Documenso "LOI process" deals have no documensoID and no curated
|
||||
// client-portal/EOIs copy; their signed PDF lives only as a NocoDB attachment
|
||||
// in the `database` bucket. The main pipeline keys EOI-doc creation off
|
||||
// documensoID, so it never created a document row for them. Here we CREATE the
|
||||
// document + file + folder and link the recovered PDF. Idempotent via a
|
||||
// `nocodb_eoi_document` ledger entry per legacy interest.
|
||||
function legacyKeyFromUrl(url: string): string | null {
|
||||
// https://<host>/database/nc/uploads/... → nc/uploads/...
|
||||
const marker = `/${DATABASE_BUCKET}/`;
|
||||
const i = url.indexOf(marker);
|
||||
if (i < 0) return null;
|
||||
return decodeURIComponent(url.slice(i + marker.length));
|
||||
}
|
||||
|
||||
async function backfillOldLoiEois(
|
||||
port: { id: string; slug: string },
|
||||
legacyDb: ReturnType<typeof postgres>,
|
||||
) {
|
||||
const rows = (await legacyDb`
|
||||
select id, "EOI_Document"::text as doc
|
||||
from plplouets5zw1um."Interests"
|
||||
where "EOI_Document" is not null and "EOI_Document"::text not in ('', '[]', 'null')
|
||||
`) as unknown as Array<{ id: number; doc: string }>;
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let unmatched = 0;
|
||||
const unresolved: string[] = [];
|
||||
|
||||
for (const r of rows) {
|
||||
let url: string | null = null;
|
||||
let title: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(r.doc) as unknown;
|
||||
const first = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (first && typeof first === 'object') {
|
||||
const rec = first as Record<string, unknown>;
|
||||
if (typeof rec.url === 'string') url = rec.url;
|
||||
if (typeof rec.title === 'string') title = rec.title;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed attachment JSON
|
||||
}
|
||||
const key = url ? legacyKeyFromUrl(url) : null;
|
||||
if (!key) {
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// legacy interest id → migrated interest
|
||||
const [link] = await db
|
||||
.select({ interestId: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||
eq(migrationSourceLinks.sourceId, String(r.id)),
|
||||
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!link) {
|
||||
unresolved.push(`legacy#${r.id} (not a migrated interest)`);
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
const interestId = link.interestId;
|
||||
|
||||
// Idempotency: skip if this attachment was already recovered.
|
||||
const [already] = await db
|
||||
.select({ id: migrationSourceLinks.id })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_eoi_document'),
|
||||
eq(migrationSourceLinks.sourceId, String(r.id)),
|
||||
eq(migrationSourceLinks.targetEntityType, 'document'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (already) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [intRow] = await db
|
||||
.select({ clientId: interests.clientId, yachtId: interests.yachtId })
|
||||
.from(interests)
|
||||
.where(eq(interests.id, interestId))
|
||||
.limit(1);
|
||||
if (!intRow?.clientId) {
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
const clientId = intRow.clientId;
|
||||
|
||||
if (DRY) {
|
||||
created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const buf = await streamToBuffer(await legacy.getObject(DATABASE_BUCKET, key));
|
||||
const docId = randomUUID();
|
||||
const storageKey = buildStoragePath(port.slug, 'eoi-signed', docId, randomUUID(), 'pdf');
|
||||
const putRes = await backend.put(storageKey, buf, {
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: buf.length,
|
||||
});
|
||||
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
|
||||
const fileName = title || key.split('/').pop() || 'eoi-signed.pdf';
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [f] = await tx
|
||||
.insert(files)
|
||||
.values({
|
||||
portId: port.id,
|
||||
filename: fileName,
|
||||
originalName: fileName,
|
||||
storagePath: putRes.key,
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: String(putRes.sizeBytes),
|
||||
category: 'eoi',
|
||||
folderId: folder.id,
|
||||
clientId,
|
||||
interestId,
|
||||
uploadedBy: 'system',
|
||||
})
|
||||
.returning({ id: files.id });
|
||||
if (!f) throw new Error('files insert returned no row');
|
||||
|
||||
await tx.insert(documents).values({
|
||||
id: docId,
|
||||
portId: port.id,
|
||||
interestId,
|
||||
clientId,
|
||||
yachtId: intRow.yachtId ?? null,
|
||||
documentType: 'eoi',
|
||||
title: `External EOI (legacy) - ${fileName}`,
|
||||
status: 'completed',
|
||||
isManualUpload: true,
|
||||
signedFileId: f.id,
|
||||
createdBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(interests)
|
||||
.set({ eoiDocStatus: 'signed', updatedAt: new Date() })
|
||||
.where(eq(interests.id, interestId));
|
||||
|
||||
await tx.insert(migrationSourceLinks).values({
|
||||
sourceSystem: 'nocodb_eoi_document',
|
||||
sourceId: String(r.id),
|
||||
targetEntityType: 'document',
|
||||
targetEntityId: docId,
|
||||
appliedId: `oldloi-${docId}`,
|
||||
appliedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
});
|
||||
created++;
|
||||
}
|
||||
return { total: rows.length, created, skipped, unmatched, unresolved };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.LEGACY_MINIO_ACCESS_KEY || !process.env.LEGACY_MINIO_SECRET_KEY) {
|
||||
console.error(
|
||||
'Set LEGACY_MINIO_ACCESS_KEY + LEGACY_MINIO_SECRET_KEY (legacy MinIO read creds).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const port = await resolvePort(slugArg);
|
||||
console.log(
|
||||
`[backfill] port=${port.slug} legacy-bucket=${LEGACY_BUCKET} ${DRY ? '(DRY RUN)' : ''}`,
|
||||
);
|
||||
|
||||
console.log('[backfill] Berth PDFs…');
|
||||
const berthRes = await backfillBerthPdfs(port);
|
||||
console.log(
|
||||
` berth PDFs: ${berthRes.total} blobs → ${berthRes.attached} attached, ${berthRes.skipped} already had one, ${berthRes.unmatched} unmatched`,
|
||||
);
|
||||
|
||||
console.log('[backfill] Signed EOIs…');
|
||||
const eoiRes = await backfillEois(port);
|
||||
console.log(
|
||||
` EOIs: ${eoiRes.totalBlobs} blobs (${eoiRes.indexedClients} client folders) · ${eoiRes.candidates} migrated EOI docs needing a file → ${eoiRes.attached} attached, ${eoiRes.unmatched} unmatched`,
|
||||
);
|
||||
if (eoiRes.unresolved.length > 0) {
|
||||
console.log(` ⚠ EOI docs with no name-matched legacy PDF (${eoiRes.unresolved.length}):`);
|
||||
for (const n of eoiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
|
||||
}
|
||||
|
||||
console.log('[backfill] Old-LOI EOIs (NocoDB `database` bucket)…');
|
||||
const legacyDb = postgres(LEGACY_DB_URL, { max: 2 });
|
||||
try {
|
||||
const loiRes = await backfillOldLoiEois(port, legacyDb);
|
||||
console.log(
|
||||
` old-LOI EOIs: ${loiRes.total} attachments → ${loiRes.created} created, ${loiRes.skipped} already done, ${loiRes.unmatched} unmatched`,
|
||||
);
|
||||
if (loiRes.unresolved.length > 0) {
|
||||
for (const n of loiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
|
||||
}
|
||||
} finally {
|
||||
await legacyDb.end().catch(() => {});
|
||||
}
|
||||
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[backfill] failed:', err);
|
||||
await closeDb().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
175
scripts/migration/connect-berth-links.ts
Normal file
175
scripts/migration/connect-berth-links.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Fix-up: connect the multi-berth links the main dedup pipeline misses.
|
||||
*
|
||||
* The dedup pipeline migrates only each interest's single `Berth Number` text
|
||||
* field; the legacy `_nc_m2m_Berths_Interests` junction (multi-berth deals) is
|
||||
* not carried over by it. This reads that junction from the `nocodb_legacy`
|
||||
* snapshot, resolves each legacy interest → its migrated interest (via the
|
||||
* ledger) and each mooring → the migrated berth, and inserts the missing
|
||||
* `interest_berths` rows.
|
||||
*
|
||||
* Idempotent: `ON CONFLICT (interest_id, berth_id) DO NOTHING`. Primary safety:
|
||||
* only makes a berth primary when the interest has no primary yet (≤1 primary
|
||||
* per interest is a partial unique index).
|
||||
*
|
||||
* Exposed as `connectBerthLinks(...)` so `migrate-from-nocodb.ts --apply` can
|
||||
* fold it into the one-shot seed; also runnable standalone:
|
||||
*
|
||||
* pnpm tsx scripts/migration/connect-berth-links.ts [--port-slug port-nimara] [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const canonMoo = (raw: string): string => {
|
||||
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
|
||||
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
|
||||
};
|
||||
|
||||
export interface ConnectBerthLinksResult {
|
||||
inserted: number;
|
||||
madePrimary: number;
|
||||
skipped: number;
|
||||
unresolved: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained: opens its own CRM + legacy connections (read-only on the
|
||||
* legacy snapshot), does the work, closes them, returns stats. Safe to call
|
||||
* from the runner or standalone.
|
||||
*/
|
||||
export async function connectBerthLinks(opts: {
|
||||
portSlug?: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<ConnectBerthLinksResult> {
|
||||
const slug = opts.portSlug ?? 'port-nimara';
|
||||
const dry = opts.dryRun ?? false;
|
||||
|
||||
const CRM_URL = process.env.DATABASE_URL!;
|
||||
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||
const crm = postgres(CRM_URL, { max: 4 });
|
||||
const legacy = postgres(LEGACY_URL, { max: 4 });
|
||||
|
||||
try {
|
||||
const [port] = await crm`select id from ports where slug=${slug} limit 1`;
|
||||
if (!port) throw new Error(`no port ${slug}`);
|
||||
const portId = port.id as string;
|
||||
|
||||
// legacy junction: interestId → set(moorings)
|
||||
const mooById = new Map<number, string>();
|
||||
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
|
||||
mooById.set(b.id as number, canonMoo(b.m as string));
|
||||
const legacyMoo = new Map<number, Set<string>>();
|
||||
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
|
||||
const set = legacyMoo.get(j.i as number) ?? new Set<string>();
|
||||
const m = mooById.get(j.b as number);
|
||||
if (m) set.add(m);
|
||||
legacyMoo.set(j.i as number, set);
|
||||
}
|
||||
// EOI-signed flag per legacy interest (for is_in_eoi_bundle)
|
||||
const signed = new Set<number>();
|
||||
for (const r of await legacy`select id, "EOI_Status" e, "LOI_NDA_Document" l from plplouets5zw1um."Interests"`) {
|
||||
const e = ((r.e as string) ?? '').trim();
|
||||
const l = ((r.l as string) ?? '').trim();
|
||||
if (
|
||||
e === 'Signed' ||
|
||||
['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l)
|
||||
)
|
||||
signed.add(r.id as number);
|
||||
}
|
||||
|
||||
// ledger: legacy interest id → new interest id
|
||||
const links =
|
||||
await crm`select source_id, target_entity_id from migration_source_links where source_system='nocodb_interests' and target_entity_type='interest'`;
|
||||
const newInterestBySrc = new Map(
|
||||
links.map((l) => [Number(l.source_id), l.target_entity_id as string]),
|
||||
);
|
||||
|
||||
// CRM berth id by mooring (this port)
|
||||
const berthByMoo = new Map(
|
||||
(await crm`select id, mooring_number m from berths where port_id=${portId}`).map((b) => [
|
||||
b.m as string,
|
||||
b.id as string,
|
||||
]),
|
||||
);
|
||||
|
||||
let inserted = 0;
|
||||
let madePrimary = 0;
|
||||
let skipped = 0;
|
||||
const unresolved: string[] = [];
|
||||
|
||||
for (const [legacyId, moorings] of legacyMoo) {
|
||||
const interestId = newInterestBySrc.get(legacyId);
|
||||
if (!interestId) continue; // not a migrated interest (backup/copy tables)
|
||||
const primaryCheck =
|
||||
await crm`select exists(select 1 from interest_berths where interest_id=${interestId} and is_primary) as has`;
|
||||
let hasPrimary = (primaryCheck[0]?.has as boolean | undefined) ?? false;
|
||||
|
||||
for (const moo of moorings) {
|
||||
const berthId = berthByMoo.get(moo);
|
||||
if (!berthId) {
|
||||
unresolved.push(`${legacyId}:${moo}`);
|
||||
continue;
|
||||
}
|
||||
const makePrimary = !hasPrimary;
|
||||
if (dry) {
|
||||
inserted++;
|
||||
if (makePrimary) {
|
||||
madePrimary++;
|
||||
hasPrimary = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const res = await crm`
|
||||
insert into interest_berths (id, interest_id, berth_id, is_primary, is_specific_interest, is_in_eoi_bundle)
|
||||
values (${randomUUID()}, ${interestId}, ${berthId}, ${makePrimary}, true, ${signed.has(legacyId)})
|
||||
on conflict (interest_id, berth_id) do nothing
|
||||
returning id`;
|
||||
if (res.length > 0) {
|
||||
inserted++;
|
||||
if (makePrimary) {
|
||||
madePrimary++;
|
||||
hasPrimary = true;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, madePrimary, skipped, unresolved };
|
||||
} finally {
|
||||
await crm.end().catch(() => {});
|
||||
await legacy.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Standalone CLI ──────────────────────────────────────────────────────────
|
||||
function isMain(): boolean {
|
||||
const arg = process.argv[1] ?? '';
|
||||
return arg.includes('connect-berth-links');
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const slugArg = (() => {
|
||||
const i = process.argv.indexOf('--port-slug');
|
||||
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||
})();
|
||||
const dry = process.argv.includes('--dry-run');
|
||||
|
||||
connectBerthLinks({ portSlug: slugArg, dryRun: dry })
|
||||
.then((r) => {
|
||||
console.log(
|
||||
`connect-berth-links ${dry ? '(DRY)' : ''}: inserted ${r.inserted} links (${r.madePrimary} new primary), ${r.skipped} already linked`,
|
||||
);
|
||||
if (r.unresolved.length)
|
||||
console.log(
|
||||
` ⚠ ${r.unresolved.length} moorings with no CRM berth: ${r.unresolved.slice(0, 20).join(', ')}`,
|
||||
);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('connect-berth-links failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
102
scripts/migration/probe-minio.ts
Normal file
102
scripts/migration/probe-minio.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Read-only MinIO inventory for the legacy → new-CRM migration (Phase 2 sizing).
|
||||
*
|
||||
* Lists every bucket the creds can see, then for the document buckets
|
||||
* (`client-portal`, `signatures`) groups objects by top-level prefix with
|
||||
* counts + sizes + samples — so we can see exactly where the EOIs, berth
|
||||
* PDFs, receipts and business-card images live before backfilling them.
|
||||
*
|
||||
* Secret-free: reads creds from env. Run with:
|
||||
* MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... \
|
||||
* pnpm tsx scripts/migration/probe-minio.ts
|
||||
*
|
||||
* Strictly read-only (listBuckets + listObjectsV2). No writes.
|
||||
*/
|
||||
import { Client } from 'minio';
|
||||
|
||||
const endPoint = process.env.MINIO_ENDPOINT || 's3.portnimara.com';
|
||||
const accessKey = process.env.MINIO_ACCESS_KEY;
|
||||
const secretKey = process.env.MINIO_SECRET_KEY;
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
console.error('Set MINIO_ACCESS_KEY and MINIO_SECRET_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({ endPoint, port: 443, useSSL: true, accessKey, secretKey });
|
||||
|
||||
interface PrefixStat {
|
||||
count: number;
|
||||
bytes: number;
|
||||
samples: string[];
|
||||
}
|
||||
|
||||
async function inventory(bucket: string) {
|
||||
const byPrefix = new Map<string, PrefixStat>();
|
||||
let total = 0;
|
||||
let totalBytes = 0;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = client.listObjectsV2(bucket, '', true);
|
||||
stream.on('data', (o) => {
|
||||
if (!o.name) return;
|
||||
total++;
|
||||
totalBytes += o.size || 0;
|
||||
const top = o.name.includes('/') ? o.name.split('/')[0] + '/' : '(root)';
|
||||
const e = byPrefix.get(top) || { count: 0, bytes: 0, samples: [] };
|
||||
e.count++;
|
||||
e.bytes += o.size || 0;
|
||||
if (e.samples.length < 4) e.samples.push(`${o.name} (${o.size}b)`);
|
||||
byPrefix.set(top, e);
|
||||
});
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', reject);
|
||||
});
|
||||
return { bucket, total, totalBytes, byPrefix };
|
||||
}
|
||||
|
||||
const mb = (b: number) => (b / 1e6).toFixed(1);
|
||||
|
||||
async function main() {
|
||||
console.log(`MinIO @ ${endPoint}\n`);
|
||||
|
||||
let buckets: string[] = [];
|
||||
try {
|
||||
const list = await client.listBuckets();
|
||||
buckets = list.map((b) => b.name);
|
||||
console.log('=== all buckets visible to these creds ===');
|
||||
for (const b of list) console.log(` ${b.name}`);
|
||||
} catch (err) {
|
||||
console.log(`listBuckets failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const targets = (process.env.MINIO_BUCKETS || 'client-portal,signatures')
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
|
||||
for (const bucket of targets) {
|
||||
if (buckets.length && !buckets.includes(bucket)) {
|
||||
console.log(`\n=== bucket: ${bucket} — NOT VISIBLE to these creds ===`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const inv = await inventory(bucket);
|
||||
console.log(
|
||||
`\n=== bucket: ${inv.bucket} — ${inv.total} objects, ${mb(inv.totalBytes)} MB ===`,
|
||||
);
|
||||
const rows = [...inv.byPrefix.entries()].sort((a, z) => z[1].count - a[1].count);
|
||||
for (const [prefix, e] of rows) {
|
||||
console.log(
|
||||
` ${prefix.padEnd(30)} ${String(e.count).padStart(5)} obj ${mb(e.bytes).padStart(8)} MB`,
|
||||
);
|
||||
for (const s of e.samples) console.log(` e.g. ${s}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`\n=== bucket: ${bucket} — ERROR: ${(err as Error).message} ===`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('probe-minio failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
277
scripts/migration/reconcile-migration.ts
Normal file
277
scripts/migration/reconcile-migration.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Exhaustive migration reconciliation (read-only): cross-checks EVERY migrated
|
||||
* record against its legacy NocoDB source row (via the migration ledger) and
|
||||
* verifies every relationship is connected. Independently re-derives the
|
||||
* expected mapped values (stage, eoiStatus, berth, …) so it validates the
|
||||
* migration logic, not just echoes it.
|
||||
*
|
||||
* Connects to BOTH local DBs:
|
||||
* - CRM : DATABASE_URL (the migrated data)
|
||||
* - legacy : LEGACY_DB_URL (the nocodb_legacy snapshot); defaults to the
|
||||
* CRM url with the db name swapped to `nocodb_legacy`.
|
||||
*
|
||||
* pnpm tsx scripts/migration/reconcile-migration.ts [--port-slug port-nimara]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const slugArg = (() => {
|
||||
const i = process.argv.indexOf('--port-slug');
|
||||
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||
})();
|
||||
|
||||
const CRM_URL = process.env.DATABASE_URL!;
|
||||
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
|
||||
const crm = postgres(CRM_URL, { max: 4 });
|
||||
const legacy = postgres(LEGACY_URL, { max: 4 });
|
||||
|
||||
// ── transforms, re-implemented independently (cross-validation) ──────────────
|
||||
const STAGE_MAP: Record<string, string> = {
|
||||
'General Qualified Interest': 'qualified',
|
||||
'Specific Qualified Interest': 'nurturing',
|
||||
'EOI and NDA Sent': 'eoi',
|
||||
'Signed EOI and NDA': 'eoi',
|
||||
'Made Reservation': 'reservation',
|
||||
'Contract Negotiation': 'contract',
|
||||
'Contract Negotiations Finalized': 'contract',
|
||||
'Contract Signed': 'contract',
|
||||
};
|
||||
const expectStage = (level: string | undefined, deposit: string | undefined): string => {
|
||||
let s = STAGE_MAP[(level ?? '').trim()] ?? 'enquiry';
|
||||
if ((deposit ?? '').trim() === 'Received' && s !== 'contract') s = 'deposit_paid';
|
||||
return s;
|
||||
};
|
||||
const expectEoi = (
|
||||
eoiStatus: string | undefined,
|
||||
loi: string | undefined,
|
||||
documensoId: string | undefined,
|
||||
): string | null => {
|
||||
const e = (eoiStatus ?? '').trim();
|
||||
const l = (loi ?? '').trim();
|
||||
if (e === 'Signed' || ['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l))
|
||||
return 'signed';
|
||||
if (e === 'Waiting for Signatures' || (documensoId ?? '').trim()) return 'waiting_for_signatures';
|
||||
return null;
|
||||
};
|
||||
const canonMoo = (raw: string): string => {
|
||||
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
|
||||
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
|
||||
};
|
||||
const normEmail = (e: string) => (e ?? '').trim().toLowerCase();
|
||||
|
||||
const issues: string[] = [];
|
||||
const add = (cat: string, msg: string) => issues.push(`[${cat}] ${msg}`);
|
||||
|
||||
async function main() {
|
||||
const [port] = await crm`select id, slug from ports where slug=${slugArg} limit 1`;
|
||||
if (!port) throw new Error(`no port ${slugArg}`);
|
||||
const portId = port.id as string;
|
||||
|
||||
// ── load legacy source (by id) ───────────────────────────────────────────
|
||||
const legacyInterests = new Map<number, Record<string, unknown>>();
|
||||
for (const r of await legacy`select * from plplouets5zw1um."Interests"`)
|
||||
legacyInterests.set(r.id as number, r);
|
||||
const legacyExpenses = new Map<number, Record<string, unknown>>();
|
||||
for (const r of await legacy`select * from p3hq2fxdevqcaq8."Expenses"`)
|
||||
legacyExpenses.set(r.id as number, r);
|
||||
const legacyRes = new Map<number, Record<string, unknown>>();
|
||||
for (const r of await legacy`select * from plplouets5zw1um."Interests (Residences)"`)
|
||||
legacyRes.set(r.id as number, r);
|
||||
// legacy berth links per interest (Interests_id -> [mooring])
|
||||
const berthMooById = new Map<number, string>();
|
||||
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
|
||||
berthMooById.set(b.id as number, b.m as string);
|
||||
const legacyBerthsByInterest = new Map<number, string[]>();
|
||||
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
|
||||
const arr = legacyBerthsByInterest.get(j.i as number) ?? [];
|
||||
const moo = berthMooById.get(j.b as number);
|
||||
if (moo) arr.push(canonMoo(moo));
|
||||
legacyBerthsByInterest.set(j.i as number, arr);
|
||||
}
|
||||
|
||||
// ── ledger ────────────────────────────────────────────────────────────────
|
||||
const ledger =
|
||||
await crm`select source_system, source_id, target_entity_type, target_entity_id from migration_source_links`;
|
||||
const interestLinks = ledger.filter((l) => l.target_entity_type === 'interest'); // sourceId(legacy interest) -> new interest
|
||||
const expenseLinks = ledger.filter((l) => l.target_entity_type === 'expense');
|
||||
const resLinks = ledger.filter((l) => l.target_entity_type === 'residential_client');
|
||||
const clientLinks = ledger.filter((l) => l.target_entity_type === 'client');
|
||||
|
||||
// ── 1. COVERAGE — every legacy row migrated; nothing dropped ──────────────
|
||||
const migratedInterestSrc = new Set(interestLinks.map((l) => Number(l.source_id)));
|
||||
const droppedInterests = [...legacyInterests.keys()].filter((id) => !migratedInterestSrc.has(id));
|
||||
const migratedExpSrc = new Set(expenseLinks.map((l) => Number(l.source_id)));
|
||||
const droppedExp = [...legacyExpenses.keys()].filter((id) => !migratedExpSrc.has(id));
|
||||
const migratedResSrc = new Set(resLinks.map((l) => Number(l.source_id)));
|
||||
const droppedRes = [...legacyRes.keys()].filter((id) => !migratedResSrc.has(id));
|
||||
for (const id of droppedInterests)
|
||||
add(
|
||||
'COVERAGE',
|
||||
`legacy interest #${id} NOT migrated (${(legacyInterests.get(id) as { Full_Name?: string }).Full_Name ?? '?'})`,
|
||||
);
|
||||
for (const id of droppedExp) add('COVERAGE', `legacy expense #${id} NOT migrated`);
|
||||
for (const id of droppedRes) add('COVERAGE', `legacy residential #${id} NOT migrated`);
|
||||
|
||||
// ── 2. INTEREST field fidelity (every migrated deal vs legacy) ────────────
|
||||
const newInterests = await crm`
|
||||
select i.id, i.pipeline_stage, i.lead_category, i.source, i.eoi_status, i.documenso_id, i.client_id, i.yacht_id
|
||||
from interests i where i.port_id=${portId}`;
|
||||
const newInterestById = new Map(newInterests.map((i) => [i.id as string, i]));
|
||||
// berths per new interest
|
||||
const ibRows = await crm`
|
||||
select ib.interest_id, b.mooring_number from interest_berths ib join berths b on b.id=ib.berth_id where b.port_id=${portId}`;
|
||||
const newBerthsByInterest = new Map<string, string[]>();
|
||||
for (const r of ibRows) {
|
||||
const a = newBerthsByInterest.get(r.interest_id as string) ?? [];
|
||||
a.push(r.mooring_number as string);
|
||||
newBerthsByInterest.set(r.interest_id as string, a);
|
||||
}
|
||||
let stageMiss = 0,
|
||||
eoiMiss = 0,
|
||||
docMiss = 0,
|
||||
berthMiss = 0;
|
||||
for (const l of interestLinks) {
|
||||
const legacyRow = legacyInterests.get(Number(l.source_id));
|
||||
const ni = newInterestById.get(l.target_entity_id as string);
|
||||
if (!legacyRow || !ni) {
|
||||
add(
|
||||
'INTEGRITY',
|
||||
`interest link sourceId=${l.source_id} → ${l.target_entity_id}: ${!legacyRow ? 'legacy row missing' : 'new interest missing'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const lr = legacyRow as Record<string, string>;
|
||||
const exp = expectStage(lr.Sales_Process_Level, lr.Deposit_10__Status);
|
||||
if (ni.pipeline_stage !== exp) {
|
||||
stageMiss++;
|
||||
add(
|
||||
'STAGE',
|
||||
`interest src#${l.source_id} (${lr.Full_Name}): legacy "${lr.Sales_Process_Level}" → expected ${exp}, got ${ni.pipeline_stage}`,
|
||||
);
|
||||
}
|
||||
const expEoi = expectEoi(lr.EOI_Status, lr.LOI_NDA_Document, lr.documensoID);
|
||||
if ((ni.eoi_status ?? null) !== expEoi) {
|
||||
eoiMiss++;
|
||||
add(
|
||||
'EOI',
|
||||
`interest src#${l.source_id} (${lr.Full_Name}): expected eoiStatus ${expEoi}, got ${ni.eoi_status}`,
|
||||
);
|
||||
}
|
||||
if ((ni.documenso_id ?? null) !== ((lr.documensoID ?? '').trim() || null)) {
|
||||
docMiss++;
|
||||
add(
|
||||
'DOCID',
|
||||
`interest src#${l.source_id} (${lr.Full_Name}): documensoId legacy="${lr.documensoID}" vs new="${ni.documenso_id}"`,
|
||||
);
|
||||
}
|
||||
// berth: every legacy-linked mooring should be present on the new interest
|
||||
const legacyMoo = new Set([...(legacyBerthsByInterest.get(Number(l.source_id)) ?? [])]);
|
||||
if (lr.Berth_Number && /^[A-Za-z]+-?0*\d+$/.test(lr.Berth_Number.trim()))
|
||||
legacyMoo.add(canonMoo(lr.Berth_Number));
|
||||
const newMoo = new Set(newBerthsByInterest.get(ni.id as string) ?? []);
|
||||
const missingBerths = [...legacyMoo].filter((m) => !newMoo.has(m));
|
||||
if (missingBerths.length > 0) {
|
||||
berthMiss++;
|
||||
add(
|
||||
'BERTH',
|
||||
`interest src#${l.source_id} (${lr.Full_Name}): legacy berths [${[...legacyMoo].join(',')}] but new has [${[...newMoo].join(',') || '-'}] (missing ${missingBerths.join(',')})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. CLIENT contact fidelity (migrated email is from a legacy source row)
|
||||
const clientContacts = await crm`
|
||||
select c.id, c.full_name, string_agg(cc.value, '|') filter (where cc.channel='email') emails
|
||||
from clients c left join client_contacts cc on cc.client_id=c.id
|
||||
where c.port_id=${portId} group by c.id, c.full_name`;
|
||||
const emailsByClient = new Map(
|
||||
clientContacts.map((c) => [
|
||||
c.id as string,
|
||||
(c.emails as string | null)?.split('|').map(normEmail) ?? [],
|
||||
]),
|
||||
);
|
||||
// group ledger client links: client -> its legacy source emails
|
||||
const legacyEmailsByClient = new Map<string, Set<string>>();
|
||||
for (const l of clientLinks) {
|
||||
const lr = legacyInterests.get(Number(l.source_id)) as Record<string, string> | undefined;
|
||||
const e = normEmail(lr?.Email_Address ?? '');
|
||||
if (!e) continue;
|
||||
const set = legacyEmailsByClient.get(l.target_entity_id as string) ?? new Set();
|
||||
set.add(e);
|
||||
legacyEmailsByClient.set(l.target_entity_id as string, set);
|
||||
}
|
||||
let emailMiss = 0;
|
||||
for (const [cid, legacyEmails] of legacyEmailsByClient) {
|
||||
const newEmails = new Set(emailsByClient.get(cid) ?? []);
|
||||
const missing = [...legacyEmails].filter((e) => !newEmails.has(e));
|
||||
if (missing.length > 0) {
|
||||
emailMiss++;
|
||||
const nm = clientContacts.find((c) => c.id === cid)?.full_name;
|
||||
add(
|
||||
'EMAIL',
|
||||
`client ${nm}: legacy email(s) [${[...legacyEmails].join(',')}] not all on client (have [${[...newEmails].join(',') || '-'}])`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. RELATIONSHIP integrity (orphans / dangling FKs) ────────────────────
|
||||
const orphanInterests =
|
||||
await crm`select count(*) n from interests i where i.port_id=${portId} and not exists (select 1 from clients c where c.id=i.client_id)`;
|
||||
const orphanIB =
|
||||
await crm`select count(*) n from interest_berths ib where not exists (select 1 from interests i where i.id=ib.interest_id) or not exists (select 1 from berths b where b.id=ib.berth_id)`;
|
||||
const orphanDocs =
|
||||
await crm`select count(*) n from documents d where d.port_id=${portId} and d.interest_id is not null and not exists (select 1 from interests i where i.id=d.interest_id)`;
|
||||
const orphanYachts =
|
||||
await crm`select count(*) n from yachts y where y.port_id=${portId} and y.current_owner_type='client' and not exists (select 1 from clients c where c.id=y.current_owner_id)`;
|
||||
const danglingSignedFile =
|
||||
await crm`select count(*) n from documents d where d.signed_file_id is not null and not exists (select 1 from files f where f.id=d.signed_file_id)`;
|
||||
if (Number(orphanInterests[0]!.n) > 0)
|
||||
add('INTEGRITY', `${orphanInterests[0]!.n} interests with no client`);
|
||||
if (Number(orphanIB[0]!.n) > 0)
|
||||
add('INTEGRITY', `${orphanIB[0]!.n} interest_berths with dangling FK`);
|
||||
if (Number(orphanDocs[0]!.n) > 0)
|
||||
add('INTEGRITY', `${orphanDocs[0]!.n} documents with dangling interest`);
|
||||
if (Number(orphanYachts[0]!.n) > 0)
|
||||
add('INTEGRITY', `${orphanYachts[0]!.n} yachts with missing owner`);
|
||||
if (Number(danglingSignedFile[0]!.n) > 0)
|
||||
add('INTEGRITY', `${danglingSignedFile[0]!.n} documents with dangling signed_file_id`);
|
||||
|
||||
// ── report ────────────────────────────────────────────────────────────────
|
||||
console.log('═══════════ MIGRATION RECONCILIATION ═══════════\n');
|
||||
console.log(
|
||||
`Coverage: legacy interests ${legacyInterests.size} → migrated ${migratedInterestSrc.size} (dropped ${droppedInterests.length})`,
|
||||
);
|
||||
console.log(
|
||||
` legacy expenses ${legacyExpenses.size} → migrated ${migratedExpSrc.size} (dropped ${droppedExp.length})`,
|
||||
);
|
||||
console.log(
|
||||
` legacy residential ${legacyRes.size} → migrated ${migratedResSrc.size} (dropped ${droppedRes.length})`,
|
||||
);
|
||||
console.log(
|
||||
`Fidelity: stage mismatches ${stageMiss} · eoiStatus ${eoiMiss} · documensoId ${docMiss} · berth-link ${berthMiss} · client-email ${emailMiss}`,
|
||||
);
|
||||
console.log(
|
||||
`Integrity: orphan interests ${orphanInterests[0]!.n} · interest_berths ${orphanIB[0]!.n} · docs ${orphanDocs[0]!.n} · yachts ${orphanYachts[0]!.n} · signed-file ${danglingSignedFile[0]!.n}`,
|
||||
);
|
||||
console.log(`\nTotal discrepancies: ${issues.length}`);
|
||||
const byCat = issues.reduce<Record<string, number>>((a, s) => {
|
||||
const c = s.slice(1, s.indexOf(']'));
|
||||
a[c] = (a[c] || 0) + 1;
|
||||
return a;
|
||||
}, {});
|
||||
console.log('By category:', JSON.stringify(byCat));
|
||||
console.log('\n── discrepancy detail (first 60) ──');
|
||||
for (const i of issues.slice(0, 60)) console.log(' ' + i);
|
||||
if (issues.length > 60) console.log(` … +${issues.length - 60} more`);
|
||||
|
||||
await crm.end();
|
||||
await legacy.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error('reconcile failed:', e);
|
||||
await crm.end().catch(() => {});
|
||||
await legacy.end().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
210
scripts/migration/verify-migration.ts
Normal file
210
scripts/migration/verify-migration.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Migration verification / audit (read-only against the local dev DB + storage).
|
||||
*
|
||||
* 1. EOI PDF ↔ person: opens each attached signed-EOI PDF, extracts its text,
|
||||
* and confirms the linked client's name actually appears inside — catching
|
||||
* any wrong attachment from the name/fuzzy matcher. Flags any PDF where a
|
||||
* *different* client's name appears instead.
|
||||
* 2. Berth PDF ↔ mooring: confirms each berth's spec-sheet PDF mentions its
|
||||
* mooring number.
|
||||
* 3. Per-person completeness: clients missing contact info, deals missing a
|
||||
* stage, clients with no deal, + a sample full dump to eyeball.
|
||||
*
|
||||
* pnpm tsx scripts/migration/verify-migration.ts [--port-slug port-nimara]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { extractText, getDocumentProxy } from 'unpdf';
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db, closeDb } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { documents, files } from '@/lib/db/schema/documents';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
|
||||
|
||||
const STORAGE_ROOT = process.env.STORAGE_ROOT || 'storage';
|
||||
const slugArg = (() => {
|
||||
const i = process.argv.indexOf('--port-slug');
|
||||
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
|
||||
})();
|
||||
|
||||
const norm = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z ]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
async function pdfText(storagePath: string): Promise<string> {
|
||||
const buf = await readFile(path.join(STORAGE_ROOT, storagePath));
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
||||
const res = await extractText(pdf, { mergePages: true });
|
||||
const t = Array.isArray(res.text) ? res.text.join(' ') : res.text;
|
||||
return norm(t);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slugArg))
|
||||
.limit(1);
|
||||
if (!port) throw new Error(`no port ${slugArg}`);
|
||||
|
||||
const allNames = (
|
||||
await db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.portId, port.id))
|
||||
).map((c) => ({
|
||||
id: c.id,
|
||||
tokens: norm(c.name)
|
||||
.split(' ')
|
||||
.filter((t) => t.length >= 4),
|
||||
name: c.name,
|
||||
}));
|
||||
|
||||
// ── 1. EOI PDF ↔ person ──────────────────────────────────────────────────
|
||||
const eoiRows = await db
|
||||
.select({
|
||||
docId: documents.id,
|
||||
clientId: documents.clientId,
|
||||
fullName: clients.fullName,
|
||||
storagePath: files.storagePath,
|
||||
})
|
||||
.from(documents)
|
||||
.innerJoin(files, eq(files.id, documents.signedFileId))
|
||||
.innerJoin(clients, eq(clients.id, documents.clientId))
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, port.id),
|
||||
eq(documents.documentType, 'eoi'),
|
||||
isNotNull(documents.signedFileId),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`\n═══ 1. EOI PDF ↔ person (${eoiRows.length} attached signed EOIs) ═══`);
|
||||
let ok = 0,
|
||||
weak = 0,
|
||||
bad = 0,
|
||||
err = 0;
|
||||
for (const r of eoiRows) {
|
||||
try {
|
||||
const text = await pdfText(r.storagePath);
|
||||
const tokens = norm(r.fullName)
|
||||
.split(' ')
|
||||
.filter((t) => t.length >= 3);
|
||||
const first = tokens[0];
|
||||
const last = tokens[tokens.length - 1];
|
||||
const hasFirst = !!first && text.includes(first);
|
||||
const hasLast = !!last && text.includes(last);
|
||||
if (hasFirst && hasLast) {
|
||||
ok++;
|
||||
} else if (hasFirst || hasLast) {
|
||||
weak++;
|
||||
console.log(
|
||||
` ⚠ WEAK "${r.fullName}" — only ${hasLast ? 'surname' : 'first name'} found in its PDF`,
|
||||
);
|
||||
} else {
|
||||
bad++;
|
||||
const other = allNames.find(
|
||||
(c) => c.id !== r.clientId && c.tokens.some((t) => text.includes(t)),
|
||||
);
|
||||
console.log(
|
||||
` ✗ BAD "${r.fullName}" — name NOT in its PDF${other ? ` — but "${other.name}" DOES appear (likely mis-attached!)` : ''}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
err++;
|
||||
console.log(` ! ERR "${r.fullName}": ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
console.log(` → strong ${ok} · weak ${weak} · NO-match ${bad} · read-error ${err}`);
|
||||
|
||||
// ── 2. Berth PDF ↔ mooring ───────────────────────────────────────────────
|
||||
const berthRows = await db
|
||||
.select({ mooring: berths.mooringNumber, storageKey: berthPdfVersions.storageKey })
|
||||
.from(berths)
|
||||
.innerJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
|
||||
.where(eq(berths.portId, port.id));
|
||||
console.log(`\n═══ 2. Berth PDF ↔ mooring (${berthRows.length} berths with a PDF) ═══`);
|
||||
let bOk = 0,
|
||||
bBad = 0,
|
||||
bErr = 0;
|
||||
for (const r of berthRows) {
|
||||
try {
|
||||
const text = await pdfText(r.storageKey);
|
||||
// mooring like "A1"/"D32" — match letter+space?+number loosely
|
||||
const moo = r.mooring.toLowerCase();
|
||||
const m = moo.match(/^([a-z]+)(\d+)$/);
|
||||
const found =
|
||||
text.includes(moo) ||
|
||||
(m && text.includes(`${m[1]} ${m[2]}`)) ||
|
||||
(m && new RegExp(`${m[1]}\\s*${m[2]}\\b`).test(text));
|
||||
if (found) bOk++;
|
||||
else {
|
||||
bBad++;
|
||||
console.log(` ✗ "${r.mooring}" mooring not found in its spec sheet`);
|
||||
}
|
||||
} catch (e) {
|
||||
bErr++;
|
||||
console.log(` ! ERR ${r.mooring}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
console.log(` → mooring-in-PDF ${bOk} · not-found ${bBad} · read-error ${bErr}`);
|
||||
|
||||
// ── 3. Per-person completeness ───────────────────────────────────────────
|
||||
console.log(`\n═══ 3. Per-person data completeness (migrated clients) ═══`);
|
||||
const noContact = await db.execute(sql`
|
||||
select c.full_name from clients c
|
||||
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||
where not exists (select 1 from client_contacts cc where cc.client_id=c.id)`);
|
||||
console.log(` clients with NO contact (email/phone): ${noContact.length}`);
|
||||
for (const r of noContact.slice(0, 15))
|
||||
console.log(` - ${(r as { full_name: string }).full_name}`);
|
||||
|
||||
const noDeal = await db.execute(sql`
|
||||
select c.full_name from clients c
|
||||
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||
where not exists (select 1 from interests i where i.client_id=c.id)`);
|
||||
console.log(` migrated clients with NO deal: ${noDeal.length}`);
|
||||
|
||||
const noStage = await db.execute(sql`
|
||||
select count(*) n from interests i
|
||||
join migration_source_links l on l.target_entity_id=i.id and l.target_entity_type='interest'
|
||||
where i.pipeline_stage is null`);
|
||||
console.log(` migrated deals with NULL stage: ${(noStage[0] as { n: number }).n}`);
|
||||
|
||||
// sample full dump to eyeball
|
||||
console.log(`\n -- sample of 6 migrated clients (eyeball) --`);
|
||||
const sample = await db.execute(sql`
|
||||
select c.full_name,
|
||||
(select string_agg(cc.channel||':'||cc.value, ', ') from client_contacts cc where cc.client_id=c.id) contacts,
|
||||
(select count(*) from interests i where i.client_id=c.id) deals,
|
||||
(select string_agg(distinct i.pipeline_stage, ',') from interests i where i.client_id=c.id) stages
|
||||
from clients c
|
||||
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
|
||||
order by deals desc nulls last limit 6`);
|
||||
for (const r of sample as unknown as Array<{
|
||||
full_name: string;
|
||||
contacts: string;
|
||||
deals: number;
|
||||
stages: string;
|
||||
}>) {
|
||||
console.log(
|
||||
` ${r.full_name} · ${r.deals} deal(s) [${r.stages}] · ${r.contacts ?? '(no contacts)'}`,
|
||||
);
|
||||
}
|
||||
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error('verify failed:', e);
|
||||
await closeDb().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
106
scripts/smoke-test-redirect.ts
Normal file
106
scripts/smoke-test-redirect.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Live smoke test for EMAIL_REDIRECT_TO.
|
||||
*
|
||||
* Actually calls `sendEmail()` (the centralized helper used by every
|
||||
* outbound email path in the app) with a fake real-client address. The
|
||||
* SMTP transporter is monkey-patched to capture the message instead of
|
||||
* actually delivering it, so this is safe to run anywhere.
|
||||
*
|
||||
* Prints the captured `to` + `subject` so the operator can see with their
|
||||
* own eyes that the redirect happened. Exits non-zero if the redirect
|
||||
* failed for any reason.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/smoke-test-redirect.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
async function main() {
|
||||
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
if (!expectedRedirect) {
|
||||
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
|
||||
console.log('');
|
||||
|
||||
// Monkey-patch nodemailer's createTransport so we capture the call
|
||||
// without actually delivering. This is the same pattern the unit
|
||||
// tests use, but at the live import-time level so we're testing the
|
||||
// exact code path that runs in production.
|
||||
const nodemailer = await import('nodemailer');
|
||||
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
|
||||
const originalCreateTransport = nodemailer.default.createTransport;
|
||||
nodemailer.default.createTransport = (() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMail: async (msg: any) => {
|
||||
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
|
||||
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
|
||||
},
|
||||
})) as unknown as typeof nodemailer.default.createTransport;
|
||||
|
||||
// Now import sendEmail (gets the patched transporter).
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
|
||||
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
|
||||
const realSubject = 'Important: Your contract is ready';
|
||||
|
||||
console.log('[smoke] calling sendEmail(...) with:');
|
||||
console.log(` to: ${realClientEmail}`);
|
||||
console.log(` subject: "${realSubject}"`);
|
||||
console.log('');
|
||||
|
||||
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
|
||||
|
||||
// Restore the original transport (be a good citizen).
|
||||
nodemailer.default.createTransport = originalCreateTransport;
|
||||
|
||||
console.log('[smoke] captured outbound message:');
|
||||
console.log(` to: ${captured[0]?.to}`);
|
||||
console.log(` subject: "${captured[0]?.subject}"`);
|
||||
console.log(` from: ${captured[0]?.from}`);
|
||||
console.log('');
|
||||
|
||||
// Assertions
|
||||
let pass = true;
|
||||
|
||||
if (captured.length !== 1) {
|
||||
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (captured[0]?.to !== expectedRedirect) {
|
||||
console.error(
|
||||
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof captured[0]?.subject !== 'string' ||
|
||||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
|
||||
) {
|
||||
console.error(
|
||||
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
|
||||
console.log(
|
||||
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('');
|
||||
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
scripts/test-currency-api.ts
Normal file
42
scripts/test-currency-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Quick verification: live Frankfurter API → DB upsert → getRate read.
|
||||
* Run with `pnpm tsx scripts/test-currency-api.ts`.
|
||||
*/
|
||||
import { refreshRates, getRate, convert } from '@/lib/services/currency';
|
||||
|
||||
async function main() {
|
||||
console.log('1. Fetching live rates from Frankfurter…');
|
||||
await refreshRates();
|
||||
|
||||
console.log('2. Reading round-trip rates from DB:');
|
||||
const usdEur = await getRate('USD', 'EUR');
|
||||
const eurUsd = await getRate('EUR', 'USD');
|
||||
const usdGbp = await getRate('USD', 'GBP');
|
||||
const eurGbp = await getRate('EUR', 'GBP');
|
||||
const usdUsd = await getRate('USD', 'USD');
|
||||
|
||||
console.log(` USD→EUR: ${usdEur}`);
|
||||
console.log(` EUR→USD: ${eurUsd}`);
|
||||
console.log(` USD→GBP: ${usdGbp}`);
|
||||
console.log(` EUR→GBP: ${eurGbp ?? '(no direct row, expected)'}`);
|
||||
console.log(` USD→USD: ${usdUsd}`);
|
||||
|
||||
console.log('3. Convert sample amounts:');
|
||||
const c1 = await convert(1000, 'USD', 'EUR');
|
||||
console.log(` $1000 → ${c1?.result} EUR @ ${c1?.rate}`);
|
||||
const c2 = await convert(500, 'EUR', 'USD');
|
||||
console.log(` €500 → $${c2?.result} @ ${c2?.rate}`);
|
||||
|
||||
// Sanity: EUR→USD should be ≈ 1 / (USD→EUR), within rounding
|
||||
if (usdEur && eurUsd) {
|
||||
const drift = Math.abs(eurUsd - 1 / usdEur);
|
||||
console.log(`4. Inverse-rate drift: ${drift.toFixed(6)} (≤0.001 = healthy)`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Currency test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
74
scripts/tsc-staged.mjs
Normal file
74
scripts/tsc-staged.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pre-commit type check for staged TS files.
|
||||
*
|
||||
* Writes a temp tsconfig that extends the project root and pins
|
||||
* `files` to whatever lint-staged passed in. `tsc -p` then compiles
|
||||
* the whole dep graph from those entrypoints — catches errors in
|
||||
* the staged code AND in anything it imports — while still skipping
|
||||
* the 22s full-project pass.
|
||||
*
|
||||
* Replaces `tsc-files` (npm), which silently fails under pnpm because
|
||||
* its tsc-resolution path (typescript/../.bin/tsc) doesn't exist in
|
||||
* pnpm's virtual store layout.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const files = args.filter((a) => /\.(ts|tsx)$/.test(a));
|
||||
|
||||
if (files.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Temp tsconfig lives inside the project tree (not /tmp) so @types/*
|
||||
// resolution walks up to node_modules. tsc's "atTypes" auto-discovery
|
||||
// is anchored to the tsconfig's directory, so a temp config in /tmp
|
||||
// would miss our @types/node, @types/react, etc.
|
||||
const baseDir = join(cwd, 'node_modules/.cache/tsc-staged');
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const tmpDir = mkdtempSync(join(baseDir, 'run-'));
|
||||
const tmpConfig = join(tmpDir, 'tsconfig.json');
|
||||
|
||||
const relFiles = files.map((f) => relative(tmpDir, resolve(cwd, f)));
|
||||
|
||||
// Pull in the project's ambient .d.ts files (css module shim,
|
||||
// react-pdf JSX augment, etc.) so side-effect imports like
|
||||
// `import 'react-pdf/dist/Page/AnnotationLayer.css'` resolve under the
|
||||
// staged-only compile. Without this, `include: []` would shut out
|
||||
// everything in src/types/ and tsc reports TS2882 for any CSS import.
|
||||
const ambientTypesGlob = relative(tmpDir, join(cwd, 'src/types')) + '/**/*.d.ts';
|
||||
|
||||
writeFileSync(
|
||||
tmpConfig,
|
||||
JSON.stringify(
|
||||
{
|
||||
extends: relative(tmpDir, join(cwd, 'tsconfig.json')),
|
||||
compilerOptions: {
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
// Explicitly list `types` so the @types/* auto-discovery
|
||||
// finds them — without this, the temp-tsconfig location
|
||||
// anchors discovery to .cache/ and misses node/react/etc.
|
||||
types: ['node', 'react', 'react-dom'],
|
||||
},
|
||||
files: relFiles,
|
||||
include: [ambientTypesGlob],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const tsc = spawnSync('pnpm', ['exec', 'tsc', '-p', tmpConfig, '--pretty'], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
process.exit(tsc.status ?? 1);
|
||||
64
scripts/tunnel-url.sh
Normal file
64
scripts/tunnel-url.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the current Cloudflare quick-tunnel URL, or a clear status line
|
||||
# if the launchd job isn't running.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/tunnel-url.sh # print URL or status
|
||||
# ./scripts/tunnel-url.sh --copy # print URL and copy to clipboard
|
||||
#
|
||||
# Paired with the launchd plist at:
|
||||
# ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist
|
||||
#
|
||||
# Quick ops:
|
||||
# launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start
|
||||
# launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop
|
||||
# launchctl kickstart -k gui/$(id -u)/solutions.letsbe.pn-crm-tunnel # restart (NEW URL)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_FILE="$HOME/Library/Logs/pn-crm-tunnel.err.log"
|
||||
LABEL="solutions.letsbe.pn-crm-tunnel"
|
||||
|
||||
if ! launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
|
||||
echo "Tunnel is not loaded. Start with:"
|
||||
echo " launchctl load ~/Library/LaunchAgents/$LABEL.plist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$LOG_FILE" ]]; then
|
||||
echo "Tunnel job is loaded but hasn't produced a log yet. Try again in a few seconds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# cloudflared prints the public URL once on startup, like:
|
||||
# https://<words>.trycloudflare.com
|
||||
# Take the most recent occurrence so a restart-then-rerun picks the
|
||||
# current one rather than a stale earlier line.
|
||||
URL=$(grep -Eo 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" | tail -1 || true)
|
||||
|
||||
if [[ -z "$URL" ]]; then
|
||||
echo "Tunnel is running but no URL has appeared in the log yet."
|
||||
echo "Tail it: tail -f $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$URL"
|
||||
echo "$URL/api/webhooks/documenso ← paste this into Documenso webhook settings"
|
||||
|
||||
if [[ "${1:-}" == "--copy" ]]; then
|
||||
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
|
||||
echo "(webhook URL copied to clipboard)"
|
||||
fi
|
||||
|
||||
# Auto-PATCH Documenso's webhook URL when the env flag is set. Gated so
|
||||
# production ports can never have their webhook rotated by a stale dev
|
||||
# script. The TS script reads DOCUMENSO_API_URL + DOCUMENSO_API_KEY +
|
||||
# DOCUMENSO_API_VERSION from .env and updates every webhook whose URL
|
||||
# already points at our path OR at any *.trycloudflare.com host.
|
||||
if [[ "${DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK:-}" == "1" ]]; then
|
||||
echo ""
|
||||
echo "DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 — updating Documenso webhook(s)…"
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 \
|
||||
pnpm tsx scripts/update-documenso-webhook.ts "$URL"
|
||||
fi
|
||||
194
scripts/update-documenso-webhook.ts
Normal file
194
scripts/update-documenso-webhook.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Documenso webhook URL auto-updater. Called by `./scripts/tunnel-url.sh`
|
||||
* when the env flag `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1` is set so a
|
||||
* freshly-restarted cloudflared quick-tunnel (which gets a NEW hostname
|
||||
* on every restart) doesn't leave Documenso pointing at a dead URL.
|
||||
*
|
||||
* Gated by env flag so production ports — which may have a stable
|
||||
* webhook URL — can never have their config rotated by a stale dev
|
||||
* script. Reads Documenso credentials from env (DOCUMENSO_API_URL +
|
||||
* DOCUMENSO_API_KEY + optional DOCUMENSO_API_VERSION).
|
||||
*
|
||||
* Usage (manual invocation):
|
||||
* DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com
|
||||
*
|
||||
* Behaviour:
|
||||
* - Lists every webhook currently configured on the Documenso
|
||||
* instance.
|
||||
* - Identifies webhooks whose `webhookUrl` looks like a
|
||||
* trycloudflare.com domain OR matches our `/api/webhooks/documenso`
|
||||
* path suffix. These are the ones to rotate.
|
||||
* - PATCHes each matching webhook to point at the new tunnel URL.
|
||||
* - Leaves all other webhooks alone (in case the instance also
|
||||
* services another tenant or a stable production URL).
|
||||
*
|
||||
* Tries Documenso v2 first, falls back to v1 if the v2 endpoint
|
||||
* returns 404. Both versions support GET /webhook(s) + PATCH on the
|
||||
* webhook resource — the shape differs slightly between them but the
|
||||
* fields we touch (`id`, `webhookUrl`) are stable across versions.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
const ENABLE_FLAG = process.env.DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK;
|
||||
const TUNNEL_BASE = process.argv[2];
|
||||
|
||||
if (ENABLE_FLAG !== '1') {
|
||||
console.log(
|
||||
'DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK is not set to 1 — skipping Documenso webhook update.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!TUNNEL_BASE) {
|
||||
console.error('Usage: pnpm tsx scripts/update-documenso-webhook.ts <tunnel-base-url>');
|
||||
console.error(
|
||||
'Example: pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const API_URL = process.env.DOCUMENSO_API_URL;
|
||||
const API_KEY = process.env.DOCUMENSO_API_KEY;
|
||||
const API_VERSION = (process.env.DOCUMENSO_API_VERSION ?? 'v2').toLowerCase();
|
||||
|
||||
if (!API_URL || !API_KEY) {
|
||||
console.error('DOCUMENSO_API_URL and DOCUMENSO_API_KEY must be set in env to update webhooks.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Trim trailing slash so we can compose paths cleanly.
|
||||
const BASE = API_URL.replace(/\/+$/, '');
|
||||
const NEW_WEBHOOK_URL = `${TUNNEL_BASE.replace(/\/+$/, '')}/api/webhooks/documenso`;
|
||||
|
||||
async function documensoRequest(path: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: API_KEY!,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface DocumensoWebhook {
|
||||
id: string | number;
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluck the array of webhooks out of whatever shape the Documenso
|
||||
* version returned. v1 historically returned an array directly; v2
|
||||
* tends to wrap in `{ data: [...] }` or similar. Be tolerant.
|
||||
*/
|
||||
function extractWebhooks(raw: unknown): DocumensoWebhook[] {
|
||||
if (Array.isArray(raw)) return raw as DocumensoWebhook[];
|
||||
if (raw && typeof raw === 'object') {
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (Array.isArray(r.data)) return r.data as DocumensoWebhook[];
|
||||
if (Array.isArray(r.webhooks)) return r.webhooks as DocumensoWebhook[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function listWebhooks(): Promise<{ webhooks: DocumensoWebhook[]; version: 'v1' | 'v2' }> {
|
||||
if (API_VERSION === 'v2' || API_VERSION === 'v2.0' || API_VERSION === 'v2.x') {
|
||||
const res = await documensoRequest('/api/v2/webhook');
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as unknown;
|
||||
return { webhooks: extractWebhooks(body), version: 'v2' };
|
||||
}
|
||||
if (res.status !== 404) {
|
||||
console.error(`v2 webhook list returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
// Fall through to v1.
|
||||
}
|
||||
const res = await documensoRequest('/api/v1/webhooks');
|
||||
if (!res.ok) {
|
||||
console.error(`v1 webhook list returned ${res.status}: ${await res.text()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
return { webhooks: extractWebhooks(body), version: 'v1' };
|
||||
}
|
||||
|
||||
async function patchWebhook(
|
||||
version: 'v1' | 'v2',
|
||||
webhook: DocumensoWebhook,
|
||||
newUrl: string,
|
||||
): Promise<boolean> {
|
||||
const path =
|
||||
version === 'v2'
|
||||
? '/api/v2/webhook'
|
||||
: `/api/v1/webhooks/${encodeURIComponent(String(webhook.id))}`;
|
||||
const body = version === 'v2' ? { id: webhook.id, webhookUrl: newUrl } : { webhookUrl: newUrl };
|
||||
const res = await documensoRequest(path, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`PATCH ${path} (id=${webhook.id}) returned ${res.status}: ${await res.text()}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a given existing webhook is "ours" (i.e. matches the
|
||||
* pattern we want to rotate). Two signals:
|
||||
* 1. Path tail matches `/api/webhooks/documenso` — the CRM-side
|
||||
* handler we own.
|
||||
* 2. Host matches `*.trycloudflare.com` — almost certainly a stale
|
||||
* quick-tunnel URL. Rotating these is always safe.
|
||||
*/
|
||||
function isRotatableWebhook(w: DocumensoWebhook): boolean {
|
||||
if (!w.webhookUrl) return false;
|
||||
if (w.webhookUrl.endsWith('/api/webhooks/documenso')) return true;
|
||||
try {
|
||||
const host = new URL(w.webhookUrl).hostname;
|
||||
if (host.endsWith('.trycloudflare.com')) return true;
|
||||
} catch {
|
||||
/* malformed — leave alone */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log(`Listing webhooks via Documenso ${API_VERSION.toUpperCase()} (base: ${BASE})…`);
|
||||
const { webhooks, version } = await listWebhooks();
|
||||
console.log(`Found ${webhooks.length} webhook(s).`);
|
||||
|
||||
const rotatable = webhooks.filter(isRotatableWebhook);
|
||||
if (rotatable.length === 0) {
|
||||
console.log(
|
||||
`No rotatable webhooks found (looking for paths ending /api/webhooks/documenso or *.trycloudflare.com hosts).`,
|
||||
);
|
||||
console.log(`If your dev webhook is configured differently, point it at: ${NEW_WEBHOOK_URL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Updating ${rotatable.length} webhook(s) to ${NEW_WEBHOOK_URL}…`);
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const w of rotatable) {
|
||||
if (w.webhookUrl === NEW_WEBHOOK_URL) {
|
||||
console.log(` ${w.id}: already at the target URL, skipping.`);
|
||||
continue;
|
||||
}
|
||||
const succeeded = await patchWebhook(version, w, NEW_WEBHOOK_URL);
|
||||
if (succeeded) {
|
||||
ok++;
|
||||
console.log(` ${w.id}: ${w.webhookUrl} -> ${NEW_WEBHOOK_URL}`);
|
||||
} else {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
console.log(`Done. ${ok} updated, ${fail} failed.`);
|
||||
if (fail > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Documenso webhook update failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
sentry.client.config.ts
Normal file
25
sentry.client.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Sentry client-side init.
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset — Sentry stays
|
||||
* shipped-but-dormant in dev. Production sets the DSN via the
|
||||
* deploy env. Sampling rate is env-driven via
|
||||
* `SENTRY_TRACES_SAMPLE_RATE` (defaults to 0.1 = 10% of transactions
|
||||
* to avoid quota burn).
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
// Replay is opt-in — we'd need to verify privacy implications
|
||||
// before enabling. Leave disabled by default.
|
||||
replaysOnErrorSampleRate: 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
});
|
||||
}
|
||||
17
sentry.edge.config.ts
Normal file
17
sentry.edge.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Sentry edge-runtime init (proxy.ts / middleware).
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset.
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
});
|
||||
}
|
||||
18
sentry.server.config.ts
Normal file
18
sentry.server.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Sentry server-side init.
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset. Same DSN as the client
|
||||
* config — Sentry routes events to the right project automatically.
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
|
||||
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Sign In',
|
||||
template: '%s | Port Nimara CRM',
|
||||
template: '%s',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center wave-watermark"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<div className="w-full max-w-md px-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const branding = await resolveAuthShellBranding();
|
||||
return <AuthBrandingProvider branding={branding}>{children}</AuthBrandingProvider>;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
|
||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||
// forwards to better-auth in one round-trip - the canonical email is never
|
||||
// returned to the browser, which closes the username-enumeration vector.
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
identifier: z.string().min(1, 'Email or username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
/**
|
||||
* H-02: Validate a redirect target before pushing the user to it. The
|
||||
* middleware appends `?redirect=<path>` when a session check fails on a
|
||||
* protected route; an unsanitized router.push of that value would let a
|
||||
* crafted URL bounce the user to an external host or protocol-relative
|
||||
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
|
||||
* slash paths pass.
|
||||
*/
|
||||
function safeRedirectTarget(raw: string | null): string {
|
||||
if (!raw) return '/dashboard';
|
||||
// Allow only paths starting with a single `/` (rules out `//evil.com`
|
||||
// protocol-relative URLs and `https://…` absolute ones).
|
||||
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const branding = useAuthBranding();
|
||||
const appName = branding?.appName?.trim() || 'CRM';
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
|
||||
// owns the first-run flow. Failure of the status endpoint is silent
|
||||
// (login still works for everyone else).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/v1/bootstrap/status')
|
||||
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { needsBootstrap?: boolean } }>) : null))
|
||||
.then((payload) => {
|
||||
if (cancelled || !payload) return;
|
||||
if (payload.data?.needsBootstrap) router.replace('/setup');
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent - login UX must still work even if status check fails */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -32,21 +77,30 @@ export default function LoginPage() {
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
const res = await fetch('/api/auth/sign-in-by-identifier', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identifier: data.identifier.trim(),
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message ?? 'Invalid email or password');
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
toast.error(payload.error?.message ?? 'Invalid credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/dashboard');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
|
||||
} catch {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
@@ -55,64 +109,63 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Marina CRM</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{appName}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{ identifier: 'Email or username', password: 'Password' }}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="identifier">Email or username</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
disabled={isLoading}
|
||||
className={cn(errors.identifier && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('identifier')}
|
||||
/>
|
||||
{errors.identifier && (
|
||||
<p className="text-sm text-destructive">{errors.identifier.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-xs text-[#0058b3] underline-offset-2 underline hover:no-underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const resetSchema = z.object({
|
||||
@@ -19,6 +22,8 @@ const resetSchema = z.object({
|
||||
type ResetFormData = z.infer<typeof resetSchema>;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -29,17 +34,41 @@ export default function ResetPasswordPage() {
|
||||
} = useForm<ResetFormData>({
|
||||
resolver: zodResolver(resetSchema),
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
// If the user landed here from a stale email link that points to
|
||||
// `/reset-password?token=…` instead of `/set-password?token=…`, hand
|
||||
// them off to the set-password form (the one that actually knows how
|
||||
// to consume the token). New emails should point straight at
|
||||
// `/set-password`, but old links live in inboxes for a long time.
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
if (token) {
|
||||
router.replace(`/set-password?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
}, [router, searchParams]);
|
||||
|
||||
async function onSubmit(data: ResetFormData) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Always show the same success message regardless of whether the email exists.
|
||||
await fetch('/api/auth/reset-password', {
|
||||
// Better-auth's request-link endpoint is `/api/auth/request-password-reset`.
|
||||
// `/api/auth/reset-password` is the *consume-token* endpoint and silently
|
||||
// rejects an email-only payload, which is why the old code appeared to
|
||||
// "succeed" without ever sending mail.
|
||||
const response = await fetch('/api/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.email }),
|
||||
body: JSON.stringify({ email: data.email, redirectTo: '/set-password' }),
|
||||
});
|
||||
|
||||
// Treat 400 "user not found" as success so we don't leak whether the
|
||||
// account exists - the success copy says "if an account exists…".
|
||||
// Anything else (5xx, network) surfaces as a real error.
|
||||
if (!response.ok && response.status !== 400) {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
@@ -49,69 +78,62 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Reset your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Check your email</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If an account exists for that email address, we have sent a password reset link.
|
||||
Please check your inbox and spam folder.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.email && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">We'll email you a link</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="font-medium text-gray-900">Check your email</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
If an account exists for that email address, we have sent a password reset link. Please
|
||||
check your inbox and spam folder.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormErrorSummary errors={errors} labels={{ email: 'Email' }} />
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Remember your password?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[#0058b3] underline-offset-2 underline hover:no-underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState, useSyncExternalStore } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
|
||||
const MIN_LENGTH = 9;
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
|
||||
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
@@ -31,25 +29,36 @@ const passwordSchema = z
|
||||
|
||||
type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
type Requirement = {
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
};
|
||||
/**
|
||||
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
||||
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
||||
* carry `?token=…` and stay functional until every outstanding invite
|
||||
* expires - drop the `?token=` fallback after that grace period.
|
||||
*/
|
||||
function readTokenFromUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const hash = window.location.hash.replace(/^#/, '');
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash);
|
||||
const fromFragment = params.get('token');
|
||||
if (fromFragment) return fromFragment;
|
||||
}
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return search.get('token') ?? '';
|
||||
}
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
|
||||
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
|
||||
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
|
||||
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
|
||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||
];
|
||||
const subscribeNoop = () => () => undefined;
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
function SetPasswordInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
// useSyncExternalStore so the fragment-only token is read post-hydration
|
||||
// (server snapshot returns null; client returns the actual value).
|
||||
const token = useSyncExternalStore<string | null>(
|
||||
subscribeNoop,
|
||||
() => readTokenFromUrl(),
|
||||
() => null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -58,10 +67,11 @@ export default function SetPasswordPage() {
|
||||
} = useForm<SetPasswordFormData>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
async function onSubmit(data: SetPasswordFormData) {
|
||||
if (!token) {
|
||||
toast.error('Invalid or missing reset token. Please request a new password reset link.');
|
||||
toast.error('Invalid or missing reset token. Please request a new link.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,8 +84,11 @@ export default function SetPasswordPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
toast.error(body.message ?? 'Failed to set password. Please try again.');
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,89 +101,101 @@ export default function SetPasswordPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-hydration: token is null. Show a loading placeholder so the user
|
||||
// doesn't see a flash of "Link is missing" while the fragment is being
|
||||
// read on the client.
|
||||
if (token === null) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div role="status" aria-live="polite" className="text-center text-sm text-gray-500">
|
||||
Loading…
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please use the link from the email we sent you. If the link is broken, ask your
|
||||
administrator for a new one.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Set your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!token ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
Invalid or missing token. Please request a new password reset link.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password', {
|
||||
onChange: (e) => setPasswordValue(e.target.value),
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1 pt-1">
|
||||
{requirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<li
|
||||
key={req.label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{ password: 'Password', confirmPassword: 'Confirm password' }}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
aria-describedby="password-hint"
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
<p id="password-hint" className="text-xs text-gray-500">
|
||||
At least {MIN_LENGTH} characters.
|
||||
</p>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword &&
|
||||
'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
|
||||
<SetPasswordInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
202
src/app/(auth)/setup/page.tsx
Normal file
202
src/app/(auth)/setup/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const setupSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(120),
|
||||
email: z.string().email('Valid email is required').max(254),
|
||||
password: z.string().min(9, 'Password must be at least 9 characters').max(200),
|
||||
confirmPassword: z.string(),
|
||||
});
|
||||
|
||||
type SetupFormData = z.infer<typeof setupSchema>;
|
||||
|
||||
interface StatusResp {
|
||||
data: { needsBootstrap: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run setup. On a fresh DB the very first visitor can claim the
|
||||
* super-admin account here. Once anyone claims it, future visits to
|
||||
* /setup redirect back to /login - the precondition is verified both
|
||||
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
||||
* internal recheck) and client-side here.
|
||||
*/
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const branding = useAuthBranding();
|
||||
const appName = branding?.appName?.trim() || 'this CRM';
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<SetupFormData>({
|
||||
resolver: zodResolver(setupSchema),
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function check() {
|
||||
try {
|
||||
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
||||
if (cancelled) return;
|
||||
if (!res.data.needsBootstrap) {
|
||||
// Already initialized - bounce to login. Replace, not push,
|
||||
// so back-button doesn't trap the user here.
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Status endpoint failed - let the user try anyway; the POST
|
||||
// does its own check and will surface a 409 if the window closed.
|
||||
} finally {
|
||||
if (!cancelled) setChecking(false);
|
||||
}
|
||||
}
|
||||
void check();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function onSubmit(data: SetupFormData) {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiFetch('/api/v1/bootstrap/super-admin', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
},
|
||||
});
|
||||
toast.success('Administrator account created - sign in to continue.');
|
||||
router.replace('/login');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center text-sm text-muted-foreground">Checking setup state…</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No administrator account exists yet. Create one to get started - you’ll be the
|
||||
super-administrator for this installation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-name">Your name</Label>
|
||||
<Input
|
||||
id="setup-name"
|
||||
placeholder="Jane Operator"
|
||||
autoComplete="name"
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-destructive')}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-email">Email</Label>
|
||||
<Input
|
||||
id="setup-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
{...register('email')}
|
||||
className={cn(errors.email && 'border-destructive')}
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-password">Password</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
placeholder="At least 9 characters"
|
||||
autoComplete="new-password"
|
||||
{...register('password')}
|
||||
className={cn(errors.password && 'border-destructive')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-confirm">Confirm password</Label>
|
||||
<Input
|
||||
id="setup-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('confirmPassword')}
|
||||
className={cn(
|
||||
watch('password') !== watch('confirmPassword') &&
|
||||
watch('confirmPassword')?.length > 0 &&
|
||||
'border-destructive',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting ? 'Creating account…' : 'Create administrator account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
This screen is only available until the first administrator is created. After that,
|
||||
subsequent users are added through Admin → Users.
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
85
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
85
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Bot, FileScan, Lightbulb } from 'lucide-react';
|
||||
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="AI configuration"
|
||||
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds are embedded below."
|
||||
eyebrow="ADMIN"
|
||||
/>
|
||||
|
||||
<RegistryDrivenForm
|
||||
title="Master controls"
|
||||
description="Hard kill switch + budget guardrails covering every AI surface in this port."
|
||||
sections={['ai.master']}
|
||||
/>
|
||||
|
||||
<RegistryDrivenForm
|
||||
title="Provider credentials"
|
||||
description="Shared API keys used by AI-enabled features. AES-encrypted at rest. Per-feature pages can override the model on a feature-by-feature basis."
|
||||
sections={['ai.providers']}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Receipt OCR
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Provider, model, and confidence thresholds for the receipt scanner. AI fallback only
|
||||
runs when the on-device parser is uncertain.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OcrSettingsForm embedded />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Berth-PDF parser AI fallback - currently configured via the
|
||||
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
||||
when one is added, it lands here so admins don't have to hunt.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileScan className="h-4 w-4" /> Berth PDF parser
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
3-tier extraction (AcroForm → on-device OCR → AI fallback on low confidence) for
|
||||
per-berth PDFs and brochures. Provider + confidence threshold are env-controlled today
|
||||
(BERTH_PDF_PARSER_PROVIDER, BERTH_PDF_PARSER_CONFIDENCE_FLOOR); a per-port override UI
|
||||
lands in a follow-up. The master switch above gates the AI tier across every port.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Future AI surfaces. Each gets a section here once it ships:
|
||||
- Recommender embeddings (currently rule-based, not LLM-based)
|
||||
- Contact-log action extraction (deferred - needs user demand)
|
||||
- Inquiry-form auto-classification (deferred)
|
||||
Listing them inert here closes the "where do I configure AI?"
|
||||
loop - admins land on /admin/ai and see the full landscape.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2 text-muted-foreground">
|
||||
<Lightbulb className="h-4 w-4" /> Planned AI surfaces
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recommender embeddings, contact-log action extraction, and inquiry-form auto-
|
||||
classification are queued. They will surface as additional sections on this page when
|
||||
shipped, with no scattered admin entries to hunt down.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function BackupManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Backup & Restore"
|
||||
eyebrow="ADMIN"
|
||||
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
|
||||
/>
|
||||
<BackupAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BulkAddBerthsWizard } from '@/components/admin/bulk-add-berths-wizard';
|
||||
|
||||
export default function BulkAddBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Bulk add berths"
|
||||
description="Create many berths at once. Pick a dock letter + range to generate the rows, then fill in per-row dimensions / pricing / pontoon. Standard fields (tenure, status) apply to every row; everything else is per-row."
|
||||
/>
|
||||
<BulkAddBerthsWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
|
||||
* pre-2026-05-22 but were only reachable via deep links from inside the
|
||||
* Berths list. Surfacing them on a dedicated admin landing tile so the
|
||||
* tools are discoverable without prior knowledge of the URL - part of
|
||||
* the admin IA regroup (B3 #10 Phase 2).
|
||||
*/
|
||||
export default async function BerthsAdminIndex({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const tools = [
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/bulk-add` as Route,
|
||||
label: 'Bulk add berths',
|
||||
description:
|
||||
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
|
||||
icon: Anchor,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/reconcile` as Route,
|
||||
label: 'Reconciliation queue',
|
||||
description:
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berths admin"
|
||||
eyebrow="ADMIN"
|
||||
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{tools.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Link key={t.href} href={t.href} className="block group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon
|
||||
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardTitle className="text-base">{t.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{t.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50/50">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
|
||||
<CardTitle className="text-sm">Not what you're looking for?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
For single-berth edits, browse to the{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/berths` as Route}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Berths list
|
||||
</Link>{' '}
|
||||
and click any row. Per-berth PDF uploads + brochure assignment also live there.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ReconcileQueue } from '@/components/admin/reconcile-queue';
|
||||
|
||||
export default function ReconcileBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth reconciliation queue"
|
||||
description="Berths flipped manually to Under Offer or Sold without a backing interest. Run the catch-up wizard on each row to create the deal, attach docs, and clear the manual flag."
|
||||
/>
|
||||
<ReconcileQueue />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
Normal file
108
src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
|
||||
import { EmailPreviewCard } from '@/components/admin/branding/email-preview-card';
|
||||
|
||||
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 0;">
|
||||
<a href="https://example.com" style="text-decoration:none;color:#1e293b;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
|
||||
Your brand name
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const DEFAULT_EMAIL_FOOTER_HTML = `<!-- Optional sub-body footer -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 0;color:#64748b;font-family:Arial,sans-serif;font-size:12px;">
|
||||
© ${new Date().getFullYear()} Your Company ·
|
||||
<a href="https://example.com" style="color:#64748b;">Visit our website</a> ·
|
||||
<a href="mailto:hello@example.com" style="color:#64748b;">hello@example.com</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'branding_app_name',
|
||||
label: 'App name',
|
||||
description: 'Shown in the email subject prefix and the in-app header.',
|
||||
type: 'string',
|
||||
placeholder: 'Port Nimara CRM',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_logo_url',
|
||||
label: 'Logo',
|
||||
description:
|
||||
'Used in email headers and the branded auth shell. Recommended: square PNG with transparent background.',
|
||||
type: 'image-upload',
|
||||
imageAspect: 1,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_email_background_url',
|
||||
label: 'Email background image',
|
||||
description:
|
||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||
type: 'image-upload',
|
||||
// 16:9 - landscape. Without an explicit aspect, the cropper falls
|
||||
// back to 1:1 and renders a circular mask (intended for avatars),
|
||||
// which is the wrong UX for a viewport-cover background.
|
||||
imageAspect: 16 / 9,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_primary_color',
|
||||
label: 'Primary color',
|
||||
description: 'Used for buttons and links in transactional email templates.',
|
||||
type: 'color',
|
||||
defaultValue: '#1e293b',
|
||||
},
|
||||
{
|
||||
key: 'branding_email_header_html',
|
||||
label: 'Email header HTML',
|
||||
description:
|
||||
'Optional HTML rendered above each email body. Leave blank to use the default. Tap "Insert default" to start from the baseline template.',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_HEADER_HTML,
|
||||
},
|
||||
{
|
||||
key: 'branding_email_footer_html',
|
||||
label: 'Email footer HTML',
|
||||
description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_FOOTER_HTML,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandingSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Branding"
|
||||
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Identity"
|
||||
description="App name, logo, and primary color."
|
||||
fields={FIELDS.slice(0, 3)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Email branding"
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<EmailPreviewCard />
|
||||
<PdfLogoUploader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
||||
|
||||
/**
|
||||
* Per-port admin page for managing brochures (Phase 7 §5.8).
|
||||
*
|
||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||
* body-size limit - see §11.1), and toggle the default flag.
|
||||
*/
|
||||
export default function BrochuresAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Brochures"
|
||||
description="Port-wide marketing PDFs available to the sales send-out flow. The default brochure is the one /clients picker pre-selects."
|
||||
/>
|
||||
<BrochuresAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
Normal file
213
src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { CheckCircle2, Info } from 'lucide-react';
|
||||
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
|
||||
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
|
||||
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
// All field arrays removed - every Documenso setting now flows through
|
||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||
// source badge on each field. The settings themselves live in
|
||||
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
||||
// `.signers` / `.templates` / `.behavior`.
|
||||
|
||||
export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Signing service (Documenso)"
|
||||
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Info className="h-4 w-4" aria-hidden="true" />
|
||||
v1 vs v2 - what changes when you flip the API version
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
|
||||
backwards compatibility. v2 is recommended for new ports and unlocks the features below.
|
||||
Switching versions does <strong>not</strong> require any code changes - version-aware
|
||||
client methods pick the right endpoint per port. Switch, save, then run the
|
||||
test-connection button to confirm the chosen instance is actually on the matching
|
||||
Documenso version.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
v2-only capabilities the CRM already uses when you pick v2
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Bulk field placement.</strong> One API call per envelope vs. v1's
|
||||
per-field POST loop. Faster contract generation, fewer transient retries on
|
||||
multi-field uploaded contracts.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
|
||||
- coordinates are portable across page sizes. v1 requires us to assume A4 for
|
||||
auto-placed fields.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Richer field metadata.</strong> TEXT labels & required flags, NUMBER
|
||||
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults - all ignored
|
||||
by v1, surfaced by v2 in the signing UI.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
|
||||
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
|
||||
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> - all routed
|
||||
through the same dedup + audit pipeline as v1 events.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
|
||||
<code>POST /envelope/create</code> (multipart),{' '}
|
||||
<code>POST /envelope/distribute</code>, <code>POST /envelope/redistribute</code>,{' '}
|
||||
<code>GET /envelope/{'{id}'}/download</code> - all routed through{' '}
|
||||
<code>/api/v2/envelope/...</code> when v2 is selected. The template-generate path
|
||||
is intentionally still v1 (relies on Documenso 2.x's backward-compat window -
|
||||
see the deferred-roadmap below).
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>One-call send.</strong> v2's <code>/envelope/distribute</code>{' '}
|
||||
returns per-recipient <code>signingUrl</code> in the same response - v1 requires a
|
||||
separate GET to fetch them. Faster send flow on the rep side.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Sequential signing enforcement.</strong> Pick SEQUENTIAL in the "v2
|
||||
signing behaviour" card below and Documenso 2.x refuses to email recipient
|
||||
N+1 until recipient N has signed. Eliminates the "approver signed before the
|
||||
developer did" race on EOIs.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Post-signing redirect URL.</strong> Set in the "v2 signing
|
||||
behaviour" card; Documenso redirects the signer to that URL after they
|
||||
complete signing. Use to land clients on the marketing site's success page or
|
||||
back in the portal instead of Documenso's default thank-you page. (v1 honours
|
||||
this too - listed here because the admin setting was added with the v2 work.)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||
v2 capabilities deferred (would need new code paths)
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
<li>
|
||||
<strong>
|
||||
Single-shot <code>/template/use</code>
|
||||
</strong>{' '}
|
||||
with v2 <code>prefillFields</code> by ID - current EOI flow uses{' '}
|
||||
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
|
||||
<code>formValues</code> keyed by name. v2 instances accept both during their
|
||||
backward-compat window; full migration requires per-template field-ID capture in
|
||||
admin settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
Update envelope metadata after creation (<code>/envelope/update</code>)
|
||||
</strong>{' '}
|
||||
- change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
|
||||
re-generating.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> - APPROVER role is already
|
||||
used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
|
||||
Useful for sales managers who want a copy without a signature slot.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Sequential signing and post-signing redirect URL <strong>are now wired</strong> - see
|
||||
the new "v2 signing behaviour" card below to configure them.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RegistryDrivenForm
|
||||
title="Documenso API"
|
||||
description="Per-port API credentials. AES-encrypted at rest. Leave blank to inherit from the env fallback (badged below each field)."
|
||||
sections={['documenso.api']}
|
||||
extra={<DocumensoTestButton />}
|
||||
/>
|
||||
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.behavior']}
|
||||
title="Signing behaviour"
|
||||
description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances."
|
||||
/>
|
||||
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.signers']}
|
||||
title="Signers (developer + approver)"
|
||||
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional - when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
|
||||
/>
|
||||
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.templates']}
|
||||
title="Templates & signing pathway"
|
||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||
extra={<TemplateSyncButton />}
|
||||
/>
|
||||
|
||||
<EmbeddedSigningCard />
|
||||
|
||||
<WebhookHealthCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
|
||||
|
||||
export default function DuplicatesAdminPage() {
|
||||
return <DuplicatesReviewQueue />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
|
||||
|
||||
export default function EmailTemplatesPage() {
|
||||
return <EmailTemplatesAdmin />;
|
||||
}
|
||||
61
src/app/(dashboard)/[portSlug]/admin/email/page.tsx
Normal file
61
src/app/(dashboard)/[portSlug]/admin/email/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Email Settings"
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||
/>
|
||||
|
||||
{/* Explainer for the "two accounts" model - addresses the recurring
|
||||
UAT question "why are there separate SMTP credentials for sales
|
||||
and noreply?". Keeps the answer in front of the admin before
|
||||
they reach the per-card form below. */}
|
||||
<div className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">Why two accounts?</strong> Transactional emails
|
||||
(signing invites, notifications, password resets) ship from your noreply mailbox over
|
||||
the SMTP credentials below. Rep-authored sales emails (one-off messages, proposal
|
||||
sends) ship from the sales mailbox with separate credentials so replies land in a
|
||||
human-monitored inbox.
|
||||
</p>
|
||||
<p>
|
||||
The noreply credentials are also used by the supplemental-info workflow + portal
|
||||
activation, i.e. anywhere the platform sends on its own initiative. The sales
|
||||
credentials are only used when a rep clicks Send in the compose UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registry-driven so each field shows the "Using env fallback /
|
||||
port / global / default" badge inline - admins can tell at a
|
||||
glance which fields are coming from .env vs. UI overrides. */}
|
||||
<RegistryDrivenForm
|
||||
sections={['email.from']}
|
||||
title="From address (noreply)"
|
||||
description="Identity headers used by system-generated emails. Set the From + Reply-To here; the matching SMTP credentials live in the next card."
|
||||
/>
|
||||
<RegistryDrivenForm
|
||||
sections={['email.smtp']}
|
||||
title="SMTP transport overrides (noreply)"
|
||||
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||
/>
|
||||
<SmtpTestSendCard />
|
||||
<TestTemplateCard />
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
249
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Copy, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
import type { LikelyCulprit } from '@/lib/error-classifier';
|
||||
|
||||
interface DetailResponse {
|
||||
data: ErrorEvent & { likelyCulprit: LikelyCulprit | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view for a single captured error. Shows everything an admin
|
||||
* needs to triage:
|
||||
*
|
||||
* - Request shape: method, path, status, duration, who fired it
|
||||
* - Error: name, message, full stack head, (sanitized) request body
|
||||
* - Likely-culprit hint: heuristic-driven plain-English root-cause
|
||||
* - Raw metadata: pg SQLSTATE codes, internal-message debug strings
|
||||
*/
|
||||
export default function ErrorEventDetailPage() {
|
||||
const params = useParams<{ portSlug: string; requestId: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const requestId = params?.requestId ?? '';
|
||||
|
||||
// Smart-back target: send the user back to the error list, not the
|
||||
// generic Administration page that URL-derivation would land on.
|
||||
useBreadcrumbHint(
|
||||
portSlug
|
||||
? {
|
||||
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||
current: `Error ${requestId.slice(0, 8)}…`,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const query = useQuery<DetailResponse>({
|
||||
queryKey: ['admin', 'error-events', requestId],
|
||||
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied`);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const event = query.data?.data;
|
||||
if (!event) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
Error event not found. It may have been pruned or you may not have access.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
{event.likelyCulprit && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{event.likelyCulprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => copy(requestId, 'Reference ID')}>
|
||||
<Copy className="mr-1.5 h-3 w-3" />
|
||||
Copy ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{event.likelyCulprit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" /> Likely culprit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p className="font-medium">{event.likelyCulprit.label}</p>
|
||||
<p className="text-muted-foreground mt-1">{event.likelyCulprit.hint}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Subsystem: <code className="font-mono">{event.likelyCulprit.subsystem}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* If the captured error has a registered code on its metadata,
|
||||
* surface the canonical user-facing message + status from the
|
||||
* registry so the admin can compare what the user saw to what
|
||||
* the system actually did. */}
|
||||
{(() => {
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const code = typeof meta.code === 'string' ? meta.code : null;
|
||||
if (!code || !isErrorCode(code)) return null;
|
||||
const def = ERROR_CODES[code];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error code</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{def.status}</Badge>
|
||||
<code className="font-mono text-xs font-semibold">{code}</code>
|
||||
</div>
|
||||
<p className="mt-2">{def.userMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compare to the message the user saw in their toast.{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/admin/errors/codes` as Route}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
All codes →
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Request</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<KV label="Method" value={event.method} />
|
||||
<KV label="Path" value={event.path} mono />
|
||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
|
||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||
<KV label="IP" value={event.ipAddress ?? '-'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '-'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<KV label="Name" value={event.errorName ?? '-'} mono />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorMessage ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
{event.errorStack && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Stack (truncated)</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copy(event.errorStack ?? '', 'Stack')}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3 w-3" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{event.requestBodyExcerpt && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Request body (sanitized, max 1 KB)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.requestBodyExcerpt}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{event.metadata !== null &&
|
||||
typeof event.metadata === 'object' &&
|
||||
Object.keys(event.metadata as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-auto rounded bg-muted p-2 text-xs font-mono">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, value, mono }: { label: string; value: string | null; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
133
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BookOpen, Search } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { ERROR_CODES } from '@/lib/error-codes';
|
||||
|
||||
/**
|
||||
* Error-code reference page surfaced inside the admin section so an
|
||||
* admin investigating a captured error_events row can flip to this
|
||||
* tab, look up the code the user reported, and read the canonical
|
||||
* plain-language meaning + status code without leaving the app.
|
||||
*
|
||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||
* automatically - adding an entry to the registry adds a row here.
|
||||
*/
|
||||
export default function ErrorCodeReferencePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Smart-back target: send the user back to the error inspector, not
|
||||
// the generic Administration page URL-derivation would land on.
|
||||
useBreadcrumbHint(
|
||||
portSlug
|
||||
? {
|
||||
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||
current: 'Error code reference',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(ERROR_CODES) as Array<
|
||||
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
||||
>;
|
||||
if (!search.trim()) return all;
|
||||
const q = search.trim().toLowerCase();
|
||||
return all.filter(
|
||||
([code, def]) => code.toLowerCase().includes(q) || def.userMessage.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
// Group by domain prefix (the part before the first underscore) so
|
||||
// the table reads naturally - Expenses, Berths, Storage, etc.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const prefix = entry[0].split('_')[0] ?? 'OTHER';
|
||||
const bucket = groups.get(prefix) ?? [];
|
||||
bucket.push(entry);
|
||||
groups.set(prefix, bucket);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" /> Error code reference
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Every error code the platform can return, with its HTTP status and the plain-language
|
||||
message a user sees. Codes are stable identifiers - once shipped, they never get
|
||||
renamed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search code or message…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
No codes match "{search}".
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map(([prefix, items]) => (
|
||||
<Card key={prefix}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prefix}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{items.map(([code, def]) => (
|
||||
<div key={code} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
def.status >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: def.status >= 400
|
||||
? 'border-amber-300 text-amber-800'
|
||||
: 'border-muted'
|
||||
}
|
||||
>
|
||||
{def.status}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs font-semibold">{code}</p>
|
||||
<p className="text-sm mt-0.5">{def.userMessage}</p>
|
||||
{'hint' in def && typeof def.hint === 'string' && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { AlertTriangle, BookOpen, Search, Wrench } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { classifyError } from '@/lib/error-classifier';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
|
||||
interface ListResponse {
|
||||
data: ErrorEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Super-admin error inspector.
|
||||
*
|
||||
* Shows the most recent captured 5xx errors with: when, where (HTTP
|
||||
* method + path), what (error name + message), and a heuristic
|
||||
* "likely culprit" badge driven by `classifyError`. Click into any
|
||||
* row for the full stack + body excerpt + raw metadata.
|
||||
*/
|
||||
export default function AdminErrorsPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const query = useQuery<ListResponse>({
|
||||
queryKey: ['admin', 'error-events', { statusFilter }],
|
||||
queryFn: () => {
|
||||
const search = new URLSearchParams();
|
||||
if (statusFilter) search.set('statusCode', statusFilter);
|
||||
return apiFetch<ListResponse>(
|
||||
`/api/v1/admin/error-events${search.toString() ? `?${search.toString()}` : ''}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const events = query.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Error inspector"
|
||||
description="Captured 5xx errors. Click any row for the full stack, request body excerpt, and likely culprit."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors/codes` as Route}>
|
||||
<BookOpen className="mr-1.5 h-4 w-4" />
|
||||
Code reference
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Search className="h-4 w-4" /> Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="status">
|
||||
Status code
|
||||
</label>
|
||||
<Input
|
||||
id="status"
|
||||
placeholder="e.g. 500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-8 w-32"
|
||||
/>
|
||||
</div>
|
||||
{statusFilter && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => setStatusFilter('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="No captured errors"
|
||||
description="Nothing has hit a 5xx in the selected window. That's a good thing."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{events.map((event) => {
|
||||
const culprit = classifyError(event);
|
||||
return (
|
||||
<Link
|
||||
key={event.requestId}
|
||||
href={`/${portSlug}/admin/errors/${event.requestId}` as Route}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono uppercase text-muted-foreground">
|
||||
{event.method}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{event.path}</span>
|
||||
{culprit && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{culprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{event.errorName ? `${event.errorName}: ` : ''}
|
||||
{event.errorMessage ?? '(no message)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })} ·{' '}
|
||||
{format(new Date(event.createdAt), 'MMM d HH:mm:ss')} · ID{' '}
|
||||
<code className="font-mono">{event.requestId.slice(0, 12)}…</code>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
|
||||
|
||||
export default function FormTemplatesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
|
||||
<p className="text-muted-foreground">Create and manage intake form templates</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <FormTemplateList />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,75 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function DataImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
||||
<p className="text-muted-foreground">Import data from external sources</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Data import"
|
||||
description="What you can import today and what an in-app importer will look like."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 mt-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available imports today</CardTitle>
|
||||
<CardDescription>Run from the command line until the UI catches up.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p>
|
||||
<strong>Berths from NocoDB:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Idempotent. Skips rows where <code>updated_at > last_imported_at</code> unless
|
||||
you pass <code>--force</code>. Add <code>--update-snapshot</code> to also rewrite{' '}
|
||||
<code>src/lib/db/seed-data/berths.json</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Storage backend migration:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/migrate-storage.ts
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Run after switching <code>system_settings.storage_backend</code> in System Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Seed (rebuild dev fixtures):</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm db:seed
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What this page will become</CardTitle>
|
||||
<CardDescription>Planned UI for self-serve imports.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Drag-and-drop CSV / XLSX upload with column-mapping UI.</li>
|
||||
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
|
||||
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
|
||||
<li>Per-port import history with rollback.</li>
|
||||
<li>Templates for clients, yachts, companies, berths, tenancies, expenses.</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
||||
failures don’t leave the database half-loaded.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryInbox />;
|
||||
}
|
||||
15
src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
Normal file
15
src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 2026-05-21: /admin/invitations was merged into /admin/users (Users +
|
||||
* Invitations tabs on a single page). This stub keeps old bookmarks +
|
||||
* external links working by redirecting to the canonical destination.
|
||||
*/
|
||||
export default async function InvitationsRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/admin/users`);
|
||||
}
|
||||
61
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
61
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ShieldX } from 'lucide-react';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may
|
||||
* access any page under /[portSlug]/admin.
|
||||
*
|
||||
* H-15: previously this layout silently redirected non-admins to
|
||||
* `/dashboard`, which left them staring at the dashboard with no
|
||||
* explanation of why their bookmark / shared admin link "didn't work".
|
||||
* Render an explicit 403 page instead so the URL stays on the failed
|
||||
* route and the user can see why their request was denied.
|
||||
*/
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
if (!profile?.isSuperAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<ShieldX className="h-7 w-7 text-destructive" aria-hidden />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold">Access denied</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
This area is for super-administrators only. If you believe you should have access, ask
|
||||
an administrator to grant the super-admin role on your account.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/${portSlug}/dashboard`}>Back to dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user