Compare commits
23 Commits
fix/select
...
fix/file-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb10b45e95 | ||
|
|
2893e95597 | ||
|
|
89654c20b0 | ||
|
|
4442e67832 | ||
|
|
d7e8b5ae6b | ||
|
|
03e1e19464 | ||
|
|
a3353acf44 | ||
|
|
a4a807ed1d | ||
|
|
6b8ada8d90 | ||
|
|
22899cd14c | ||
|
|
833c69a73e | ||
|
|
d9cf363302 | ||
|
|
9212b1ca2e | ||
|
|
ede2475796 | ||
|
|
e32ad1cd32 | ||
|
|
bac22021ad | ||
|
|
8d8cfa3bae | ||
|
|
971348a397 | ||
|
|
d3920cd246 | ||
|
|
5af375825b | ||
|
|
861416a18e | ||
|
|
a7e78ad580 | ||
|
|
f288047a6a |
@@ -110,13 +110,15 @@ ENV PORT=3000
|
|||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Copy the standalone Next.js server
|
# Copy the standalone Next.js server
|
||||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||||
# Copy static assets and public files
|
# Copy static assets and public files
|
||||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||||
|
|
||||||
# Copy bootstrap script for runtime env var injection
|
# Copy bootstrap script for runtime env var injection
|
||||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||||
|
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["bootstrap.cjs"]
|
CMD ["bootstrap.cjs"]
|
||||||
|
|||||||
@@ -555,7 +555,11 @@ checksums:
|
|||||||
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
|
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
|
||||||
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
|
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
|
||||||
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||||
|
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
|
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
|
||||||
|
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
|
||||||
|
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
|
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
|
||||||
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
|
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
|
||||||
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||||
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||||
@@ -603,10 +607,10 @@ checksums:
|
|||||||
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
|
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
|
||||||
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||||
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
|
|
||||||
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
|
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
|
||||||
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
|
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
|
||||||
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
|
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
|
||||||
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||||
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
|
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
|
||||||
@@ -1009,6 +1013,7 @@ checksums:
|
|||||||
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
|
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
|
||||||
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
|
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||||
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||||
|
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
|
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
|
||||||
06EQqT/message: e9f56489fb395a890ae2557200c29a36
|
06EQqT/message: e9f56489fb395a890ae2557200c29a36
|
||||||
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
|
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||||
@@ -1259,9 +1264,9 @@ checksums:
|
|||||||
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||||
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
|
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||||
2POOFK/translation: 0326365539c004f6088656f692602078
|
2POOFK/translation: 0326365539c004f6088656f692602078
|
||||||
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
|
|
||||||
8LLt89/message: 9a78178dc1874543d35153ae325e2811
|
8LLt89/message: 9a78178dc1874543d35153ae325e2811
|
||||||
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
|
||||||
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
|
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
|
||||||
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||||
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
|
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
|
||||||
@@ -1485,9 +1490,9 @@ checksums:
|
|||||||
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
|
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
|
||||||
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
|
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
|
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
|
||||||
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
|
|
||||||
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
|
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
|
||||||
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
|
||||||
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
|
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
|
||||||
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
|
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
|
||||||
@@ -1633,17 +1638,17 @@ checksums:
|
|||||||
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
|
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||||
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
|
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
|
|
||||||
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
|
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
|
||||||
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
|
||||||
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
|
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
|
||||||
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||||
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
|
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
|
||||||
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||||
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
|
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
|
||||||
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
|
|
||||||
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
|
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
|
||||||
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
|
||||||
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
|
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||||
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||||
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
|
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
|
||||||
@@ -1915,9 +1920,9 @@ checksums:
|
|||||||
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
|
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
|
||||||
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||||
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
|
|
||||||
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
|
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
|
||||||
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
|
||||||
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
|
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
|
||||||
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||||
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||||
@@ -2011,8 +2016,9 @@ checksums:
|
|||||||
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
||||||
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
|
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
|
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
|
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||||
|
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
|
||||||
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
||||||
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||||
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||||
@@ -2043,7 +2049,8 @@ checksums:
|
|||||||
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||||
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
||||||
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
||||||
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||||
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
|
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
|
||||||
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
|
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
|
||||||
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
@@ -2187,9 +2194,13 @@ checksums:
|
|||||||
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
||||||
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||||
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
||||||
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
|
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||||
|
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||||
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
|
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
|
||||||
MpFIca/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
MpFIca/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
MpFIca/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||||
|
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
|
||||||
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
||||||
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||||
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
||||||
@@ -2287,10 +2298,10 @@ checksums:
|
|||||||
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
||||||
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||||
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
||||||
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
|
|
||||||
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
|
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
|
||||||
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
|
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
|
||||||
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||||
|
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
|
||||||
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
|
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||||
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||||
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
|
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||||
@@ -2351,11 +2362,11 @@ checksums:
|
|||||||
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
|
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
|
||||||
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||||
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
|
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
|
||||||
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
|
|
||||||
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
|
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
|
||||||
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||||
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||||
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
|
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
|
||||||
|
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
|
||||||
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
||||||
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
||||||
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
||||||
@@ -2390,7 +2401,8 @@ checksums:
|
|||||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||||
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||||
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||||
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||||
CAL6E9/message: b63448c05270497973ac4407047dae02
|
CAL6E9/message: b63448c05270497973ac4407047dae02
|
||||||
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||||
@@ -2519,6 +2531,9 @@ checksums:
|
|||||||
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
|
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
|
||||||
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||||
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
|
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
|
||||||
|
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
|
||||||
|
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||||
|
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
|
||||||
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
|
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
|
||||||
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||||
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
|
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
|
||||||
@@ -2627,6 +2642,7 @@ checksums:
|
|||||||
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
|
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
|
||||||
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
|
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
|
||||||
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
|
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||||
|
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||||
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
|
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
|
||||||
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
|
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
|
||||||
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||||
@@ -2738,9 +2754,9 @@ checksums:
|
|||||||
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
|
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
|
||||||
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||||
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
|
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
|
||||||
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
|
|
||||||
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
|
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
|
||||||
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||||
|
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
|
||||||
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
|
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
|
||||||
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||||
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||||
@@ -2750,9 +2766,9 @@ checksums:
|
|||||||
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
|
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
|
||||||
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||||
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||||
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
|
|
||||||
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
|
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
|
||||||
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||||
|
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
|
||||||
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
|
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
|
||||||
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||||
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
|
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
|
||||||
@@ -3048,6 +3064,10 @@ checksums:
|
|||||||
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
|
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
|
||||||
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||||
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
|
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
|
||||||
|
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
|
||||||
|
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
|
||||||
|
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||||
|
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
|
||||||
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
|
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
|
||||||
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||||
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
|
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import CommandPallette from "./CommandPallette";
|
import CommandPallette from "./CommandPallette";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
|
|||||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { data: hasPartnerSlot } =
|
||||||
|
api.workspace.hasAvailablePartnerSlot.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -150,11 +153,19 @@ export default function WorkspaceMenu({
|
|||||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
openModal("NEW_WORKSPACE");
|
||||||
: openModal("NEW_WORKSPACE")
|
} else if (hasPartnerSlot) {
|
||||||
}
|
router.push(
|
||||||
|
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
{t`Create workspace`}
|
{t`Create workspace`}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -4,12 +4,13 @@ export interface TierConfig {
|
|||||||
plan: WorkspacePlan;
|
plan: WorkspacePlan;
|
||||||
seats: number | null;
|
seats: number | null;
|
||||||
unlimitedSeats: boolean;
|
unlimitedSeats: boolean;
|
||||||
|
workspaceSlots: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_MAP: Record<number, TierConfig> = {
|
const TIER_MAP: Record<number, TierConfig> = {
|
||||||
1: { plan: "team", seats: 5, unlimitedSeats: false },
|
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||||
2: { plan: "pro", seats: 15, unlimitedSeats: false },
|
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||||
3: { plan: "pro", seats: null, unlimitedSeats: true },
|
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function tierConfig(tier: number): TierConfig {
|
export function tierConfig(tier: number): TierConfig {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createNextApiContext } from "@kan/api/trpc";
|
|||||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
|
|
||||||
import { tierConfig } from "./_utils";
|
import { tierConfig } from "./_utils";
|
||||||
@@ -110,11 +109,15 @@ export default withRateLimit(
|
|||||||
const { db, user } = await createNextApiContext(req);
|
const { db, user } = await createNextApiContext(req);
|
||||||
|
|
||||||
const cfg = tierConfig(license.tier);
|
const cfg = tierConfig(license.tier);
|
||||||
const isActive = license.status === "active";
|
const status = license.status === "active" ? "active" : "inactive";
|
||||||
const status = isActive ? "active" : "inactive";
|
|
||||||
|
|
||||||
if (!user) {
|
// Ensure subscription slots exist — webhook may have already created them
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license.license_key,
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
db,
|
db,
|
||||||
license.license_key,
|
license.license_key,
|
||||||
{
|
{
|
||||||
@@ -124,45 +127,18 @@ export default withRateLimit(
|
|||||||
seats: cfg.seats,
|
seats: cfg.seats,
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
},
|
},
|
||||||
|
cfg.workspaceSlots,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
return res.redirect(
|
||||||
const workspace = memberships?.[0]?.workspace;
|
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
|
);
|
||||||
if (!workspace) {
|
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
|
||||||
db,
|
|
||||||
license.license_key,
|
|
||||||
{
|
|
||||||
plan: cfg.plan,
|
|
||||||
status,
|
|
||||||
partnerTier: license.tier,
|
|
||||||
seats: cfg.seats,
|
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return res.redirect(
|
|
||||||
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
|
|
||||||
plan: cfg.plan,
|
|
||||||
status,
|
|
||||||
partnerTier: license.tier,
|
|
||||||
seats: cfg.seats,
|
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
|
||||||
referenceId: workspace.publicId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.redirect(`/?partner_activated=1`);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,38 +27,72 @@ export default withRateLimit(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
|
||||||
if (!sub) {
|
if (!allSlots.length) {
|
||||||
return res.redirect("/boards?partner_error=invalid_license");
|
return res.redirect("/boards?partner_error=invalid_license");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.status !== "active") {
|
const activeSlots = allSlots.filter((s) =>
|
||||||
|
["active", "trialing"].includes(s.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeSlots.length) {
|
||||||
return res.redirect("/boards?partner_error=license_inactive");
|
return res.redirect("/boards?partner_error=license_inactive");
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||||
const workspace = memberships?.[0]?.workspace;
|
|
||||||
|
|
||||||
if (!workspace) {
|
if (!unlinkedSlot) {
|
||||||
|
return res.redirect("/boards?partner_activated=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedIds = new Set(
|
||||||
|
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
|
const availableWorkspace = memberships
|
||||||
|
.map((m) => m.workspace)
|
||||||
|
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||||
|
|
||||||
|
if (!availableWorkspace) {
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||||
plan: sub.plan,
|
referenceId: availableWorkspace.publicId,
|
||||||
status: sub.status,
|
|
||||||
partnerTier: sub.partnerTier ?? 1,
|
|
||||||
seats: sub.seats ?? null,
|
|
||||||
unlimitedSeats: sub.unlimitedSeats,
|
|
||||||
referenceId: workspace.publicId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await workspaceRepo.update(db, workspace.publicId, {
|
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||||
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
|
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const remainingUnlinked = activeSlots.filter(
|
||||||
|
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingUnlinked.length > 0) {
|
||||||
|
const updatedLinkedIds = new Set([
|
||||||
|
...linkedIds,
|
||||||
|
availableWorkspace.publicId,
|
||||||
|
]);
|
||||||
|
const hasMoreAvailableWorkspace = memberships
|
||||||
|
.map((m) => m.workspace)
|
||||||
|
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||||
|
|
||||||
|
if (hasMoreAvailableWorkspace) {
|
||||||
|
return res.redirect(
|
||||||
|
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.redirect("/boards?partner_activated=1");
|
return res.redirect("/boards?partner_activated=1");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,17 +2,112 @@ import { createHmac, timingSafeEqual } from "crypto";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
import { getActiveSubscriptions } from "@kan/shared/utils";
|
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import type { TierConfig } from "./_utils";
|
||||||
import { tierConfig } from "./_utils";
|
import { tierConfig } from "./_utils";
|
||||||
|
|
||||||
const log = createLogger("api");
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
async function createAndLinkSlots(
|
||||||
|
db: dbClient,
|
||||||
|
licenseKey: string,
|
||||||
|
cfg: TierConfig,
|
||||||
|
tier: number,
|
||||||
|
alreadyLinkedReferenceIds: Set<string>,
|
||||||
|
count: number,
|
||||||
|
) {
|
||||||
|
const linkedId = [...alreadyLinkedReferenceIds][0];
|
||||||
|
let autoLinked = 0;
|
||||||
|
|
||||||
|
if (linkedId) {
|
||||||
|
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
|
||||||
|
|
||||||
|
if (linkedWorkspace?.createdBy) {
|
||||||
|
const owned = await workspaceRepo.getAllOwnedByUserId(
|
||||||
|
db,
|
||||||
|
linkedWorkspace.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = owned.filter(
|
||||||
|
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
const existingSubs =
|
||||||
|
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
|
||||||
|
db,
|
||||||
|
candidates.map((w) => w.publicId),
|
||||||
|
);
|
||||||
|
const alreadySubscribed = new Set(
|
||||||
|
existingSubs
|
||||||
|
.map((s) => s.referenceId)
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const available = candidates
|
||||||
|
.filter((w) => !alreadySubscribed.has(w.publicId))
|
||||||
|
.slice(0, count);
|
||||||
|
|
||||||
|
if (available.length > 0) {
|
||||||
|
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
licenseKey,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
available.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
available.map((workspace, i) => {
|
||||||
|
const slot = newSlots[i];
|
||||||
|
if (!slot) return;
|
||||||
|
return Promise.all([
|
||||||
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
referenceId: workspace.publicId,
|
||||||
|
}),
|
||||||
|
workspaceRepo.update(db, workspace.publicId, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
autoLinked = available.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = count - autoLinked;
|
||||||
|
if (remaining > 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
licenseKey,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buffer(readable: Readable) {
|
async function buffer(readable: Readable) {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of readable) {
|
for await (const chunk of readable) {
|
||||||
@@ -42,6 +137,12 @@ function verifySignature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||||
|
s: T,
|
||||||
|
): s is T & { referenceId: string } {
|
||||||
|
return !!s.referenceId;
|
||||||
|
}
|
||||||
|
|
||||||
interface WebhookPayload {
|
interface WebhookPayload {
|
||||||
event:
|
event:
|
||||||
| "purchase"
|
| "purchase"
|
||||||
@@ -93,107 +194,212 @@ export default withApiLogging(
|
|||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "purchase": {
|
case "purchase":
|
||||||
const cfg = tierConfig(tier);
|
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
|
||||||
plan: cfg.plan,
|
|
||||||
status: license_status,
|
|
||||||
partnerTier: tier,
|
|
||||||
seats: cfg.seats,
|
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "activate": {
|
case "activate": {
|
||||||
const cfg = tierConfig(tier);
|
const cfg = tierConfig(tier);
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
plan: cfg.plan,
|
db,
|
||||||
status: "active",
|
license_key,
|
||||||
partnerTier: tier,
|
);
|
||||||
seats: cfg.seats,
|
const status = event === "activate" ? "active" : license_status;
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
|
||||||
});
|
if (existing.length === 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
cfg.workspaceSlots,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "deactivate": {
|
case "deactivate": {
|
||||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
db,
|
db,
|
||||||
license_key,
|
license_key,
|
||||||
);
|
);
|
||||||
if (sub) {
|
|
||||||
const [, allSubs] = await Promise.all([
|
await Promise.all(
|
||||||
subscriptionRepo.updateById(db, sub.id, {
|
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||||
plan: "free",
|
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||||
status: "inactive",
|
db,
|
||||||
}),
|
slot.referenceId,
|
||||||
sub.referenceId
|
|
||||||
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
if (sub.referenceId) {
|
|
||||||
const hasActiveSub = getActiveSubscriptions(allSubs).some(
|
|
||||||
(s) => s.id !== sub.id,
|
|
||||||
);
|
);
|
||||||
if (!hasActiveSub) {
|
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||||
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
|
(s) => s.id !== slot.id,
|
||||||
|
);
|
||||||
|
if (!hasOtherActiveSub) {
|
||||||
|
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
}
|
);
|
||||||
|
|
||||||
|
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: "free",
|
||||||
|
status: "canceled",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
case "downgrade": {
|
case "downgrade": {
|
||||||
const lookupKey = prev_license_key ?? license_key;
|
const lookupKey = prev_license_key ?? license_key;
|
||||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
db,
|
db,
|
||||||
lookupKey,
|
lookupKey,
|
||||||
);
|
);
|
||||||
if (sub) {
|
|
||||||
const cfg = tierConfig(tier);
|
if (existing.length === 0) break;
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
|
||||||
plan: cfg.plan,
|
const cfg = tierConfig(tier);
|
||||||
status: "active",
|
const newCount = cfg.workspaceSlots;
|
||||||
partnerTier: tier,
|
|
||||||
seats: cfg.seats,
|
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
const preferKeep = [
|
||||||
referenceId: sub.referenceId ?? undefined,
|
...existing.filter((s) => s.referenceId),
|
||||||
});
|
...existing.filter((s) => !s.referenceId),
|
||||||
if (prev_license_key) {
|
];
|
||||||
await subscriptionRepo.updateById(db, sub.id, {
|
|
||||||
status: "inactive",
|
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||||
});
|
const slotsToRemove = preferKeep.slice(newCount);
|
||||||
}
|
|
||||||
if (sub.referenceId) {
|
await Promise.all([
|
||||||
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
|
...slotsToKeep.map((slot) =>
|
||||||
}
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
partnerTier: tier,
|
||||||
|
status: "active",
|
||||||
|
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...slotsToKeep
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) =>
|
||||||
|
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (newCount > existing.length) {
|
||||||
|
const linkedIds = new Set(
|
||||||
|
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||||
|
);
|
||||||
|
await createAndLinkSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
cfg,
|
||||||
|
tier,
|
||||||
|
linkedIds,
|
||||||
|
newCount - existing.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slotsToRemove.length > 0) {
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToRemove
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||||
|
...slotsToRemove.map((s) =>
|
||||||
|
subscriptionRepo.updateById(db, s.id, {
|
||||||
|
plan: "free",
|
||||||
|
status: "inactive",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "migrate": {
|
case "migrate": {
|
||||||
if (prev_license_key) {
|
if (!prev_license_key) break;
|
||||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
|
||||||
db,
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
prev_license_key,
|
db,
|
||||||
);
|
prev_license_key,
|
||||||
if (sub) {
|
);
|
||||||
const cfg = tierConfig(tier);
|
if (existing.length === 0) break;
|
||||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
const newCount = cfg.workspaceSlots;
|
||||||
|
|
||||||
|
const preferKeep = [
|
||||||
|
...existing.filter((s) => s.referenceId),
|
||||||
|
...existing.filter((s) => !s.referenceId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||||
|
const slotsToRemove = preferKeep.slice(newCount);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToKeep.map((slot) =>
|
||||||
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
partnerLicenseKey: license_key,
|
||||||
plan: cfg.plan,
|
plan: cfg.plan,
|
||||||
status: "active",
|
status: "active",
|
||||||
partnerTier: tier,
|
partnerTier: tier,
|
||||||
seats: cfg.seats,
|
seats: cfg.seats,
|
||||||
unlimitedSeats: cfg.unlimitedSeats,
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
referenceId: sub.referenceId ?? undefined,
|
}),
|
||||||
});
|
),
|
||||||
await subscriptionRepo.updateById(db, sub.id, {
|
...slotsToKeep
|
||||||
status: "inactive",
|
.filter(hasReferenceId)
|
||||||
});
|
.map((s) =>
|
||||||
}
|
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (newCount > existing.length) {
|
||||||
|
const linkedIds = new Set(
|
||||||
|
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||||
|
);
|
||||||
|
await createAndLinkSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
cfg,
|
||||||
|
tier,
|
||||||
|
linkedIds,
|
||||||
|
newCount - existing.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slotsToRemove.length > 0) {
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToRemove
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||||
|
...slotsToRemove.map((s) =>
|
||||||
|
subscriptionRepo.updateById(db, s.id, {
|
||||||
|
plan: "free",
|
||||||
|
status: "inactive",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,15 @@ export default withRateLimit(
|
|||||||
return res.status(400).json({ error: "File too large" });
|
return res.status(400).json({ error: "File too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalFilenameHeader =
|
const rawFilenameHeader =
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
const originalFilenameHeader = (() => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(rawFilenameHeader);
|
||||||
|
} catch {
|
||||||
|
return rawFilenameHeader;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
const sanitizedFilename = originalFilenameHeader
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
|||||||
@@ -64,8 +64,15 @@ export default withRateLimit(
|
|||||||
return res.status(400).json({ error: "File too large" });
|
return res.status(400).json({ error: "File too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalFilenameHeader =
|
const rawFilenameHeader =
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
const originalFilenameHeader = (() => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(rawFilenameHeader);
|
||||||
|
} catch {
|
||||||
|
return rawFilenameHeader;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
const sanitizedFilename = originalFilenameHeader
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
|||||||
|
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||||
|
|
||||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||||
setIsMagicLinkSent(value);
|
setIsMagicLinkSent(value);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||||
import { HiCheckBadge } from "react-icons/hi2";
|
import { HiCheckBadge } from "react-icons/hi2";
|
||||||
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": file.type,
|
"Content-Type": file.type,
|
||||||
"x-original-filename": file.name,
|
"x-original-filename": encodeURIComponent(file.name),
|
||||||
},
|
},
|
||||||
body: file,
|
body: file,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
HiHashtag,
|
HiHashtag,
|
||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
|
HiOutlineDocumentDuplicate,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import Dropdown from "~/components/Dropdown";
|
|||||||
import { usePermissions } from "~/hooks/usePermissions";
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
export default function CardDropdown({
|
export default function CardDropdown({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
@@ -20,19 +22,44 @@ export default function CardDropdown({
|
|||||||
boardPublicId,
|
boardPublicId,
|
||||||
cardCreatedBy,
|
cardCreatedBy,
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
|
listPublicId,
|
||||||
|
cardIndex,
|
||||||
}: {
|
}: {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
boardPublicId?: string;
|
boardPublicId?: string;
|
||||||
cardCreatedBy?: string | null;
|
cardCreatedBy?: string | null;
|
||||||
ticketNumber?: string | null;
|
ticketNumber?: string | null;
|
||||||
|
listPublicId?: string;
|
||||||
|
cardIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { canEditCard, canDeleteCard } = usePermissions();
|
const { canEditCard, canDeleteCard } = usePermissions();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
const utils = api.useUtils();
|
||||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||||
|
|
||||||
|
const duplicateCard = api.card.duplicate.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Card duplicated`,
|
||||||
|
icon: "success",
|
||||||
|
message: t`Card duplicated successfully.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to duplicate card`,
|
||||||
|
icon: "error",
|
||||||
|
message: t`Please try again.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await utils.board.byId.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCopyCardLink = async () => {
|
const handleCopyCardLink = async () => {
|
||||||
const path =
|
const path =
|
||||||
isTemplate && boardPublicId
|
isTemplate && boardPublicId
|
||||||
@@ -99,6 +126,24 @@ export default function CardDropdown({
|
|||||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t`Duplicate card`,
|
||||||
|
action: () => {
|
||||||
|
if (!listPublicId || cardIndex === undefined) return;
|
||||||
|
duplicateCard.mutate({
|
||||||
|
cardPublicId,
|
||||||
|
listPublicId,
|
||||||
|
index: cardIndex + 1,
|
||||||
|
copyLabels: true,
|
||||||
|
copyMembers: true,
|
||||||
|
copyChecklists: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: (
|
||||||
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
disabled: duplicateCard.isPending || !listPublicId,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(canDeleteCard || isCreator
|
...(canDeleteCard || isCreator
|
||||||
|
|||||||
@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
const {
|
||||||
|
data: card,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = api.card.byId.useQuery(
|
||||||
{ cardPublicId: cardId ?? "" },
|
{ cardPublicId: cardId ?? "" },
|
||||||
{ enabled: !!cardId && cardId.length >= 12 },
|
{ enabled: !!cardId && cardId.length >= 12 },
|
||||||
);
|
);
|
||||||
@@ -340,14 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
>
|
>
|
||||||
{board?.name}
|
{board?.name}
|
||||||
</Link>
|
</Link>
|
||||||
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
|
{card.cardNumber != null &&
|
||||||
<>
|
card.list.board.workspace.cardPrefix && (
|
||||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
<>
|
||||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||||
</span>
|
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -356,10 +361,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
boardPublicId={boardId}
|
boardPublicId={boardId}
|
||||||
cardCreatedBy={card?.createdBy}
|
cardCreatedBy={card?.createdBy}
|
||||||
ticketNumber={
|
ticketNumber={
|
||||||
card.cardNumber != null && card.list.board.workspace.cardPrefix
|
card.cardNumber != null &&
|
||||||
|
card.list.board.workspace.cardPrefix
|
||||||
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
listPublicId={card?.list.publicId}
|
||||||
|
cardIndex={card?.index}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ export default function InvitePage() {
|
|||||||
return router.push(`/boards`);
|
return router.push(`/boards`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error.data?.code === "FORBIDDEN" &&
|
||||||
|
error.message === "SEAT_LIMIT_REACHED"
|
||||||
|
) {
|
||||||
|
setError(
|
||||||
|
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
|
||||||
|
);
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(
|
setError(
|
||||||
error.message ||
|
error.message ||
|
||||||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ import { api } from "~/utils/api";
|
|||||||
export function InviteMemberForm({
|
export function InviteMemberForm({
|
||||||
subscriptions,
|
subscriptions,
|
||||||
unlimitedSeats,
|
unlimitedSeats,
|
||||||
|
memberCount,
|
||||||
|
seatLimit,
|
||||||
}: {
|
}: {
|
||||||
subscriptions: Subscription[] | undefined;
|
subscriptions: Subscription[] | undefined;
|
||||||
unlimitedSeats: boolean;
|
unlimitedSeats: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
seatLimit: number | null;
|
||||||
}) {
|
}) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||||
@@ -93,6 +97,15 @@ export function InviteMemberForm({
|
|||||||
message: t`User is already a member of this workspace`,
|
message: t`User is already a member of this workspace`,
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
|
} else if (
|
||||||
|
error.data?.code === "FORBIDDEN" &&
|
||||||
|
error.message === "SEAT_LIMIT_REACHED"
|
||||||
|
) {
|
||||||
|
showPopup({
|
||||||
|
header: t`Seat limit reached`,
|
||||||
|
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Error inviting member`,
|
header: t`Error inviting member`,
|
||||||
@@ -285,21 +298,27 @@ export function InviteMemberForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!isPartnerTier &&
|
(isPartnerTier && seatLimit !== null ? (
|
||||||
!unlimitedSeats && (
|
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||||
|
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
|
||||||
|
</span>
|
||||||
|
<span className="text-light-900 dark:text-dark-900">
|
||||||
|
{memberCount} / {seatLimit} {t`seats`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !unlimitedSeats ? (
|
||||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||||
{hasTeamSubscription || hasProSubscription ? (
|
{hasTeamSubscription || hasProSubscription ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||||
</span>
|
</span>
|
||||||
{!isPartnerTier && (
|
<p className="mt-1">
|
||||||
<p className="mt-1">
|
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||||
{unlimitedSeats
|
</p>
|
||||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
|
||||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -312,7 +331,7 @@ export function InviteMemberForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import type { Subscription } from "@kan/shared/utils";
|
import type { Subscription } from "@kan/shared/utils";
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
import {
|
||||||
|
getSeatLimit,
|
||||||
|
getSubscriptionByPlan,
|
||||||
|
hasUnlimitedSeats,
|
||||||
|
} from "@kan/shared/utils";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
@@ -84,6 +88,10 @@ export default function MembersPage() {
|
|||||||
const isPaidPlan = isProPlan || isTeamPlan;
|
const isPaidPlan = isProPlan || isTeamPlan;
|
||||||
|
|
||||||
const activeMembers = data?.members.length ?? 0;
|
const activeMembers = data?.members.length ?? 0;
|
||||||
|
const seatLimit = getSeatLimit(subscriptions);
|
||||||
|
const memberCount =
|
||||||
|
data?.members.filter((m) => m.status === "active" || m.status === "invited")
|
||||||
|
.length ?? 0;
|
||||||
const totalSeats =
|
const totalSeats =
|
||||||
teamSubscription?.seats ??
|
teamSubscription?.seats ??
|
||||||
proSubscription?.seats ??
|
proSubscription?.seats ??
|
||||||
@@ -402,6 +410,8 @@ export default function MembersPage() {
|
|||||||
<InviteMemberForm
|
<InviteMemberForm
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
unlimitedSeats={unlimitedSeats}
|
unlimitedSeats={unlimitedSeats}
|
||||||
|
memberCount={memberCount}
|
||||||
|
seatLimit={seatLimit}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export default function WorkspaceNameView() {
|
|||||||
const billing = searchParams.get("billing") ?? "annual";
|
const billing = searchParams.get("billing") ?? "annual";
|
||||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||||
const licenseKeyParam = searchParams.get("license_key");
|
const licenseKeyParam = searchParams.get("license_key");
|
||||||
const isLicenseFlow = !!licenseKeyParam;
|
const isLicenseFlow =
|
||||||
|
!!licenseKeyParam || searchParams.get("partner") === "1";
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,6 +104,7 @@ export default function WorkspaceNameView() {
|
|||||||
if (!workspace.publicId) return;
|
if (!workspace.publicId) return;
|
||||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||||
void utils.workspace.all.invalidate();
|
void utils.workspace.all.invalidate();
|
||||||
|
void utils.workspace.hasAvailablePartnerSlot.invalidate();
|
||||||
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
||||||
if (storedLicenseKey) {
|
if (storedLicenseKey) {
|
||||||
localStorage.removeItem("partnerLicenseKey");
|
localStorage.removeItem("partnerLicenseKey");
|
||||||
|
|||||||
@@ -95,8 +95,28 @@ export function CardModal({
|
|||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
<div className="h-full p-8">
|
<div className="h-full p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-start justify-between gap-4">
|
||||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
<div className="flex-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.cardNumber != null &&
|
||||||
|
data.list.board.workspace.cardPrefix && (
|
||||||
|
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
||||||
|
{data.list.board.workspace.cardPrefix}-
|
||||||
|
{data.cardNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h1 className="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
|
{data?.title}
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyCardLink}
|
onClick={handleCopyCardLink}
|
||||||
@@ -134,22 +154,6 @@ export function CardModal({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
|
|
||||||
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
|
||||||
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
|
||||||
{data?.title}
|
|
||||||
</h1>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{labels.length > 0 && (
|
{labels.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function Avatar({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": blob.type,
|
"Content-Type": blob.type,
|
||||||
"x-original-filename": fileName,
|
"x-original-filename": encodeURIComponent(fileName),
|
||||||
},
|
},
|
||||||
body: blob,
|
body: blob,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"./utils/permissions": {
|
"./utils/permissions": {
|
||||||
"types": "./src/utils/permissions.ts",
|
"types": "./src/utils/permissions.ts",
|
||||||
"default": "./src/utils/permissions.ts"
|
"default": "./src/utils/permissions.ts"
|
||||||
|
},
|
||||||
|
"./utils/workspace": {
|
||||||
|
"types": "./src/utils/workspace.ts",
|
||||||
|
"default": "./src/utils/workspace.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
|
|||||||
@@ -1347,7 +1347,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
checklistId: newChecklist.id,
|
checklistId: newChecklist.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
completed: item.completed ?? false,
|
completed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
|||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import {
|
import {
|
||||||
generateUID,
|
generateUID,
|
||||||
|
getSeatLimit,
|
||||||
getSubscriptionByPlan,
|
getSubscriptionByPlan,
|
||||||
hasUnlimitedSeats,
|
hasUnlimitedSeats,
|
||||||
} from "@kan/shared";
|
} from "@kan/shared";
|
||||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
|
||||||
import { memberInviteResponseSchema } from "../schemas";
|
import { memberInviteResponseSchema } from "../schemas";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
assertCanManageMember,
|
assertCanManageMember,
|
||||||
assertCanManageRole,
|
assertCanManageRole,
|
||||||
@@ -114,6 +115,20 @@ export const memberRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seatLimit = getSeatLimit(subscriptions);
|
||||||
|
if (seatLimit !== null) {
|
||||||
|
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (memberCount >= seatLimit) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `SEAT_LIMIT_REACHED`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||||
@@ -644,6 +659,20 @@ export const memberRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seatLimit = getSeatLimit(subscriptions);
|
||||||
|
if (seatLimit !== null) {
|
||||||
|
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (memberCount >= seatLimit) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `SEAT_LIMIT_REACHED`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the workspace role to set roleId
|
// Get the workspace role to set roleId
|
||||||
|
|||||||
@@ -2,19 +2,21 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { WorkspacePlan } from "@kan/db/schema";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
|
||||||
import {
|
import {
|
||||||
workspaceListItemSchema,
|
|
||||||
workspaceDetailSchema,
|
|
||||||
workspaceWithBoardsSchema,
|
|
||||||
workspaceCreateResponseSchema,
|
workspaceCreateResponseSchema,
|
||||||
workspaceUpdateResponseSchema,
|
|
||||||
workspaceDeleteResponseSchema,
|
workspaceDeleteResponseSchema,
|
||||||
|
workspaceDetailSchema,
|
||||||
|
workspaceListItemSchema,
|
||||||
|
workspaceUpdateResponseSchema,
|
||||||
|
workspaceWithBoardsSchema,
|
||||||
} from "../schemas";
|
} from "../schemas";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertPermission } from "../utils/permissions";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
|
||||||
export const workspaceRouter = createTRPCRouter({
|
export const workspaceRouter = createTRPCRouter({
|
||||||
@@ -270,12 +272,47 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let unlinkedSlot: Awaited<
|
||||||
|
ReturnType<typeof subscriptionRepo.getFirstUnlinkedSlotByLicenseKey>
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||||
|
const otherWorkspaceIds = memberships
|
||||||
|
.map((m) => m.workspace?.publicId)
|
||||||
|
.filter((id): id is string => !!id && id !== workspacePublicId);
|
||||||
|
|
||||||
|
const partnerSub = otherWorkspaceIds.length
|
||||||
|
? await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||||
|
ctx.db,
|
||||||
|
otherWorkspaceIds,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
unlinkedSlot = partnerSub?.partnerLicenseKey
|
||||||
|
? await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||||
|
ctx.db,
|
||||||
|
partnerSub.partnerLicenseKey,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (unlinkedSlot) {
|
||||||
|
await Promise.all([
|
||||||
|
subscriptionRepo.updateById(ctx.db, unlinkedSlot.id, {
|
||||||
|
referenceId: workspacePublicId,
|
||||||
|
}),
|
||||||
|
workspaceRepo.update(ctx.db, workspacePublicId, {
|
||||||
|
plan: unlinkedSlot.plan as WorkspacePlan,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicId: result.publicId,
|
publicId: result.publicId,
|
||||||
name: result.name!,
|
name: result.name!,
|
||||||
slug: result.slug!,
|
slug: result.slug!,
|
||||||
description: result.description ?? null,
|
description: result.description ?? null,
|
||||||
plan: result.plan!,
|
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
|
||||||
cardPrefix: result.cardPrefix!,
|
cardPrefix: result.cardPrefix!,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -412,10 +449,21 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||||
|
|
||||||
await workspaceRepo.hardDelete(
|
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||||
ctx.db,
|
const subs = await subscriptionRepo.getByReferenceId(
|
||||||
input.workspacePublicId,
|
ctx.db,
|
||||||
);
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
subs
|
||||||
|
.filter((s) => !!s.partnerLicenseKey)
|
||||||
|
.map((s) =>
|
||||||
|
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
@@ -560,4 +608,38 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
hasAvailablePartnerSlot: protectedProcedure
|
||||||
|
.input(z.void())
|
||||||
|
.output(z.boolean())
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `User not authenticated`,
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||||
|
const workspaceIds = memberships
|
||||||
|
.map((m) => m.workspace?.publicId)
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
if (!workspaceIds.length) return false;
|
||||||
|
|
||||||
|
const partnerSub =
|
||||||
|
await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||||
|
ctx.db,
|
||||||
|
workspaceIds,
|
||||||
|
);
|
||||||
|
if (!partnerSub?.partnerLicenseKey) return false;
|
||||||
|
|
||||||
|
const unlinkedSlot =
|
||||||
|
await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||||
|
ctx.db,
|
||||||
|
partnerSub.partnerLicenseKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return !!unlinkedSlot;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const cardDetailSchema = z.object({
|
|||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
cardNumber: z.number().nullable(),
|
cardNumber: z.number().nullable(),
|
||||||
|
index: z.number(),
|
||||||
dueDate: z.date().nullable(),
|
dueDate: z.date().nullable(),
|
||||||
createdBy: z.string().nullable(),
|
createdBy: z.string().nullable(),
|
||||||
labels: z.array(labelSchema),
|
labels: z.array(labelSchema),
|
||||||
|
|||||||
40
packages/api/src/utils/workspace.ts
Normal file
40
packages/api/src/utils/workspace.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
export const cancelWorkspaceAccess = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspacePublicId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
|
||||||
|
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
const preserveUserId = await memberRepo.getPreservableMemberId(
|
||||||
|
db,
|
||||||
|
workspace.id,
|
||||||
|
workspace.createdBy ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
let newSlug = workspace.publicId;
|
||||||
|
if (workspace.slug !== workspace.publicId) {
|
||||||
|
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||||
|
db,
|
||||||
|
workspace.publicId,
|
||||||
|
);
|
||||||
|
if (!isPublicIdAvailable) {
|
||||||
|
newSlug = generateUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
preserveUserId
|
||||||
|
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
|
||||||
|
: memberRepo.pauseAllMembers(db, workspace.id),
|
||||||
|
workspaceRepo.update(db, workspacePublicId, {
|
||||||
|
plan: "free",
|
||||||
|
slug: newSlug,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
const log = createLogger("auth");
|
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
import { socialProvidersPlugin } from "./providers";
|
import { socialProvidersPlugin } from "./providers";
|
||||||
import { triggerWorkflow } from "./utils";
|
import { triggerWorkflow } from "./utils";
|
||||||
|
|
||||||
|
const log = createLogger("auth");
|
||||||
|
|
||||||
|
async function cancelWorkspaceAccess(
|
||||||
|
db: dbClient,
|
||||||
|
workspacePublicId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
|
||||||
|
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
const preserveUserId = await memberRepo.getPreservableMemberId(
|
||||||
|
db,
|
||||||
|
workspace.id,
|
||||||
|
workspace.createdBy ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
let newSlug = workspace.publicId;
|
||||||
|
if (workspace.slug !== workspace.publicId) {
|
||||||
|
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||||
|
db,
|
||||||
|
workspace.publicId,
|
||||||
|
);
|
||||||
|
if (!isPublicIdAvailable) {
|
||||||
|
newSlug = generateUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
preserveUserId
|
||||||
|
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
|
||||||
|
: memberRepo.pauseAllMembers(db, workspace.id),
|
||||||
|
workspaceRepo.update(db, workspacePublicId, {
|
||||||
|
plan: "free",
|
||||||
|
slug: newSlug,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function createPlugins(db: dbClient) {
|
export function createPlugins(db: dbClient) {
|
||||||
return [
|
return [
|
||||||
socialProvidersPlugin(),
|
socialProvidersPlugin(),
|
||||||
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
|
|||||||
unlimitedSeats: true,
|
unlimitedSeats: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
|
log.info(
|
||||||
|
{ subscriptionId: stripeSubscription.id },
|
||||||
|
"Pro subscription activated with unlimited seats",
|
||||||
|
);
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
db,
|
db,
|
||||||
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
|
|||||||
subscription,
|
subscription,
|
||||||
cancellationDetails,
|
cancellationDetails,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
|
onSubscriptionDeleted: async ({ subscription }) => {
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
await cancelWorkspaceAccess(db, subscription.referenceId);
|
||||||
db,
|
|
||||||
subscription.referenceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (workspace?.id) {
|
|
||||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
|
||||||
|
|
||||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
|
||||||
let newSlug = workspace.publicId;
|
|
||||||
|
|
||||||
if (workspace.slug !== workspace.publicId) {
|
|
||||||
const isPublicIdAvailable =
|
|
||||||
await workspaceRepo.isWorkspaceSlugAvailable(
|
|
||||||
db,
|
|
||||||
workspace.publicId,
|
|
||||||
);
|
|
||||||
if (!isPublicIdAvailable) {
|
|
||||||
newSlug = generateUID();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await workspaceRepo.update(db, subscription.referenceId, {
|
|
||||||
plan: "free",
|
|
||||||
slug: newSlug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSubscriptionUpdate: async ({ subscription }) => {
|
onSubscriptionUpdate: async ({ subscription }) => {
|
||||||
await triggerWorkflow(db, "subscription-updated", subscription);
|
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||||
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
|
|||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
|
log.info(
|
||||||
|
{ email, isInvite: decodedUrl.includes("type=invite") },
|
||||||
|
"Sending magic link",
|
||||||
|
);
|
||||||
if (decodedUrl.includes("type=invite")) {
|
if (decodedUrl.includes("type=invite")) {
|
||||||
let inviterName = "";
|
let inviterName = "";
|
||||||
let workspaceName = "";
|
let workspaceName = "";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";
|
||||||
3939
packages/db/migrations/meta/20260529123231_snapshot.json
Normal file
3939
packages/db/migrations/meta/20260529123231_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
|||||||
"when": 1778617946519,
|
"when": 1778617946519,
|
||||||
"tag": "20260512203226_AddPartnerLicenseToSubscription",
|
"tag": "20260512203226_AddPartnerLicenseToSubscription",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780057951781,
|
||||||
|
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,12 @@ export const create = async (
|
|||||||
cardNumber,
|
cardNumber,
|
||||||
dueDate: cardInput.dueDate ?? null,
|
dueDate: cardInput.dueDate ?? null,
|
||||||
})
|
})
|
||||||
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
|
.returning({
|
||||||
|
id: cards.id,
|
||||||
|
listId: cards.listId,
|
||||||
|
publicId: cards.publicId,
|
||||||
|
cardNumber: cards.cardNumber,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result[0]) throw new Error("Unable to create card");
|
if (!result[0]) throw new Error("Unable to create card");
|
||||||
|
|
||||||
@@ -321,8 +326,7 @@ export const bulkCreate = async (
|
|||||||
.where(eq(workspaces.id, workspaceId))
|
.where(eq(workspaces.id, workspaceId))
|
||||||
.returning({ cardCounter: workspaces.cardCounter });
|
.returning({ cardCounter: workspaces.cardCounter });
|
||||||
|
|
||||||
if (!counterResult)
|
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
|
||||||
throw new Error(`Workspace ${workspaceId} not found`);
|
|
||||||
|
|
||||||
const last = counterResult.cardCounter;
|
const last = counterResult.cardCounter;
|
||||||
const start = last - count + 1;
|
const start = last - count + 1;
|
||||||
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
dueDate: true,
|
dueDate: true,
|
||||||
createdBy: true,
|
createdBy: true,
|
||||||
cardNumber: true,
|
cardNumber: true,
|
||||||
|
index: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, count, eq, isNull } from "drizzle-orm";
|
import { and, count, eq, isNull, ne, or } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
||||||
@@ -19,6 +19,27 @@ export const getActiveCount = async (db: dbClient) => {
|
|||||||
return result[0]?.count ?? 0;
|
return result[0]?.count ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCountByWorkspaceId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
const result = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
or(
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
eq(workspaceMembers.status, "invited"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result[0]?.count ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
memberInput: {
|
memberInput: {
|
||||||
@@ -71,14 +92,14 @@ export const getByPublicIdsWithUsers = async (
|
|||||||
return db.query.workspaceMembers.findMany({
|
return db.query.workspaceMembers.findMany({
|
||||||
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
||||||
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
||||||
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
conditions.push(eq(members.workspaceId, workspaceId));
|
conditions.push(eq(members.workspaceId, workspaceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
conditions.push(eq(members.status, "active"));
|
conditions.push(eq(members.status, "active"));
|
||||||
conditions.push(isNullFn(members.deletedAt));
|
conditions.push(isNullFn(members.deletedAt));
|
||||||
|
|
||||||
return and(...conditions);
|
return and(...conditions);
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
@@ -172,6 +193,68 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPreservableMemberId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
ownerUserId: string | null,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
if (ownerUserId) {
|
||||||
|
const owner = await db.query.workspaceMembers.findFirst({
|
||||||
|
columns: { userId: true },
|
||||||
|
where: and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.userId, ownerUserId),
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (owner?.userId) return owner.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await db.query.workspaceMembers.findFirst({
|
||||||
|
columns: { userId: true },
|
||||||
|
where: and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.role, "admin"),
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: (m, { asc }) => [asc(m.createdAt)],
|
||||||
|
});
|
||||||
|
if (admin?.userId) return admin.userId;
|
||||||
|
|
||||||
|
const anyMember = await db.query.workspaceMembers.findFirst({
|
||||||
|
columns: { userId: true },
|
||||||
|
where: and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: (m, { asc }) => [asc(m.createdAt)],
|
||||||
|
});
|
||||||
|
return anyMember?.userId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pauseMembersExcept = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
preserveUserId: string,
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.update(workspaceMembers)
|
||||||
|
.set({ status: "paused" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
or(
|
||||||
|
isNull(workspaceMembers.userId),
|
||||||
|
ne(workspaceMembers.userId, preserveUserId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const updateRole = async (
|
export const updateRole = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { subscription } from "@kan/db/schema";
|
import { subscription } from "@kan/db/schema";
|
||||||
@@ -15,6 +15,9 @@ export const updateById = async (
|
|||||||
periodEnd?: Date | null;
|
periodEnd?: Date | null;
|
||||||
cancelAtPeriodEnd?: boolean | null;
|
cancelAtPeriodEnd?: boolean | null;
|
||||||
stripeSubscriptionId?: string | null;
|
stripeSubscriptionId?: string | null;
|
||||||
|
referenceId?: string | null;
|
||||||
|
partnerLicenseKey?: string;
|
||||||
|
partnerTier?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -29,6 +32,7 @@ export const updateById = async (
|
|||||||
plan: subscription.plan,
|
plan: subscription.plan,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
unlimitedSeats: subscription.unlimitedSeats,
|
unlimitedSeats: subscription.unlimitedSeats,
|
||||||
|
referenceId: subscription.referenceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -63,6 +67,24 @@ export const updateByStripeSubscriptionId = async (
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateAllByPartnerLicenseKey = async (
|
||||||
|
db: dbClient,
|
||||||
|
partnerLicenseKey: string,
|
||||||
|
updates: {
|
||||||
|
plan?: string;
|
||||||
|
status?: string;
|
||||||
|
partnerTier?: number;
|
||||||
|
seats?: number | null;
|
||||||
|
unlimitedSeats?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return await db
|
||||||
|
.update(subscription)
|
||||||
|
.set({ ...updates, updatedAt: new Date() })
|
||||||
|
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
|
||||||
|
.returning({ id: subscription.id });
|
||||||
|
};
|
||||||
|
|
||||||
export const getByStripeSubscriptionId = async (
|
export const getByStripeSubscriptionId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
stripeSubscriptionId: string,
|
stripeSubscriptionId: string,
|
||||||
@@ -96,13 +118,63 @@ export const getByPartnerLicenseKey = async (
|
|||||||
db: dbClient,
|
db: dbClient,
|
||||||
partnerLicenseKey: string,
|
partnerLicenseKey: string,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query.subscription.findFirst({
|
return await db.query.subscription.findFirst({
|
||||||
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||||
});
|
});
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upsertByPartnerLicenseKey = async (
|
export const getAllByPartnerLicenseKey = async (
|
||||||
|
db: dbClient,
|
||||||
|
partnerLicenseKey: string,
|
||||||
|
) => {
|
||||||
|
return await db.query.subscription.findMany({
|
||||||
|
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||||
|
orderBy: [asc(subscription.id)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFirstUnlinkedSlotByLicenseKey = async (
|
||||||
|
db: dbClient,
|
||||||
|
partnerLicenseKey: string,
|
||||||
|
) => {
|
||||||
|
return await db.query.subscription.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||||
|
isNull(subscription.referenceId),
|
||||||
|
inArray(subscription.status, ["active", "trialing"]),
|
||||||
|
),
|
||||||
|
orderBy: [asc(subscription.id)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllActivePartnerSubsByWorkspaceIds = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspacePublicIds: string[],
|
||||||
|
) => {
|
||||||
|
if (workspacePublicIds.length === 0) return [];
|
||||||
|
return await db.query.subscription.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(subscription.referenceId, workspacePublicIds),
|
||||||
|
isNotNull(subscription.partnerLicenseKey),
|
||||||
|
inArray(subscription.status, ["active", "trialing"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFirstActivePartnerSubByWorkspaceIds = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspacePublicIds: string[],
|
||||||
|
) => {
|
||||||
|
return await db.query.subscription.findFirst({
|
||||||
|
where: and(
|
||||||
|
inArray(subscription.referenceId, workspacePublicIds),
|
||||||
|
isNotNull(subscription.partnerLicenseKey),
|
||||||
|
inArray(subscription.status, ["active", "trialing"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPartnerLicenseSlots = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
partnerLicenseKey: string,
|
partnerLicenseKey: string,
|
||||||
data: {
|
data: {
|
||||||
@@ -111,20 +183,13 @@ export const upsertByPartnerLicenseKey = async (
|
|||||||
partnerTier: number;
|
partnerTier: number;
|
||||||
seats: number | null;
|
seats: number | null;
|
||||||
unlimitedSeats: boolean;
|
unlimitedSeats: boolean;
|
||||||
referenceId?: string;
|
|
||||||
},
|
},
|
||||||
|
count: number,
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const rows = Array.from({ length: count }, () => ({
|
||||||
.insert(subscription)
|
partnerLicenseKey,
|
||||||
.values({
|
...data,
|
||||||
partnerLicenseKey,
|
referenceId: null,
|
||||||
...data,
|
}));
|
||||||
referenceId: data.referenceId ?? null,
|
return await db.insert(subscription).values(rows).returning();
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: subscription.partnerLicenseKey,
|
|
||||||
set: { ...data, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
|||||||
name: true,
|
name: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
where: eq(workspaces.publicId, workspacePublicId),
|
where: eq(workspaces.publicId, workspacePublicId),
|
||||||
});
|
});
|
||||||
@@ -314,6 +315,16 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
|||||||
return result.filter((member) => !member.workspace.deletedAt);
|
return result.filter((member) => !member.workspace.deletedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAllOwnedByUserId = async (db: dbClient, userId: string) => {
|
||||||
|
return await db.query.workspaces.findMany({
|
||||||
|
columns: {
|
||||||
|
publicId: true,
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
where: and(eq(workspaces.createdBy, userId), isNull(workspaces.deletedAt)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getMemberByPublicId = (db: dbClient, memberPublicId: string) => {
|
export const getMemberByPublicId = (db: dbClient, memberPublicId: string) => {
|
||||||
return db.query.workspaceMembers.findFirst({
|
return db.query.workspaceMembers.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -5,42 +5,33 @@ import {
|
|||||||
integer,
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { workspaces } from "./workspaces";
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
export const subscription = pgTable(
|
export const subscription = pgTable("subscription", {
|
||||||
"subscription",
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
{
|
plan: varchar("plan", { length: 255 }).notNull(),
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
referenceId: varchar("referenceId", { length: 12 }).references(
|
||||||
plan: varchar("plan", { length: 255 }).notNull(),
|
() => workspaces.publicId,
|
||||||
referenceId: varchar("referenceId", { length: 12 }).references(
|
{ onDelete: "set null" },
|
||||||
() => workspaces.publicId,
|
),
|
||||||
{ onDelete: "set null" },
|
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||||
),
|
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
periodStart: timestamp("periodStart"),
|
||||||
status: varchar("status", { length: 255 }).notNull(),
|
periodEnd: timestamp("periodEnd"),
|
||||||
periodStart: timestamp("periodStart"),
|
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||||
periodEnd: timestamp("periodEnd"),
|
seats: integer("seats"),
|
||||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
||||||
seats: integer("seats"),
|
trialStart: timestamp("trialStart"),
|
||||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
trialEnd: timestamp("trialEnd"),
|
||||||
trialStart: timestamp("trialStart"),
|
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
|
||||||
trialEnd: timestamp("trialEnd"),
|
partnerTier: integer("partnerTier"),
|
||||||
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
partnerTier: integer("partnerTier"),
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
}).enableRLS();
|
||||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("subscription_partner_license_key_idx").on(
|
|
||||||
table.partnerLicenseKey,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).enableRLS();
|
|
||||||
|
|
||||||
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ export const hasUnlimitedSeats = (
|
|||||||
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
||||||
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
|
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSeatLimit = (
|
||||||
|
subscriptions: Subscription[] | undefined,
|
||||||
|
): number | null => {
|
||||||
|
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
||||||
|
const partnerSub = activeSubscriptions.find(
|
||||||
|
(sub) =>
|
||||||
|
sub.partnerTier !== null && !sub.unlimitedSeats && sub.seats !== null,
|
||||||
|
);
|
||||||
|
return partnerSub?.seats ?? null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user