Compare commits

..

23 Commits

Author SHA1 Message Date
Henry
cb10b45e95 fix: encode attachment filename header to support non-ASCII characters 2026-06-09 12:11:16 +01:00
Henry
2893e95597 fix(cloud): free partner subscription slot when workspace is deleted 2026-05-31 21:21:36 +01:00
Henry
89654c20b0 fix(partner): scope auto-claim to owned workspaces and guard against active subs 2026-05-30 12:51:21 +01:00
hjball
4442e67832 chore: update translations 2026-05-29 22:53:24 +00:00
Henry
d7e8b5ae6b feat: reset checklist items to default when duplicating 2026-05-29 23:51:48 +01:00
Henry
03e1e19464 fix: ensure upgrade/downgrade paths for migrate requests 2026-05-29 23:48:01 +01:00
hjball
a3353acf44 chore: compile translations 2026-05-29 22:29:11 +00:00
hjball
a4a807ed1d chore: update translations 2026-05-29 22:29:06 +00:00
Henry
6b8ada8d90 feat: duplicate card via dropdown 2026-05-29 23:27:21 +01:00
hjball
22899cd14c chore: update translations 2026-05-29 21:59:33 +00:00
Henry
833c69a73e cloud: enable workspace slots for partners 2026-05-29 22:58:09 +01:00
hjball
d9cf363302 chore: update translations 2026-05-25 21:01:17 +00:00
Henry
9212b1ca2e fix: adjust layout of card prefix on public modal 2026-05-25 21:59:57 +01:00
hjball
ede2475796 chore: update translations 2026-05-25 20:49:46 +00:00
Henry
e32ad1cd32 fix(cloud): hide empty notice box for pro subscriptions 2026-05-25 21:48:27 +01:00
hjball
bac22021ad chore: update translations 2026-05-21 21:53:34 +00:00
Henry
8d8cfa3bae feat(cloud): pause members when subscription is cancelled 2026-05-21 22:52:17 +01:00
hjball
971348a397 chore: compile translations 2026-05-21 20:39:43 +00:00
hjball
d3920cd246 chore: update translations 2026-05-21 20:39:39 +00:00
Henry
5af375825b fix: reliably select workspace on login (#507) 2026-05-21 21:38:09 +01:00
Henry
861416a18e feat(cloud): set seat limit checks for partner member invitations (#506) 2026-05-21 21:36:57 +01:00
Henry
a7e78ad580 fix: respect redirect param on login and signup pages (#504) 2026-05-21 21:36:02 +01:00
Henry
f288047a6a fix: run web container as non-root user (UID 1000) (#501) 2026-05-21 21:35:47 +01:00
53 changed files with 6691 additions and 1609 deletions

View File

@@ -110,13 +110,15 @@ ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# 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 --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/.next/static/ ./apps/web/.next/static/
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
# 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
CMD ["bootstrap.cjs"]

View File

@@ -555,7 +555,11 @@ checksums:
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -603,10 +607,10 @@ checksums:
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
@@ -1009,6 +1013,7 @@ checksums:
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
06EQqT/message: e9f56489fb395a890ae2557200c29a36
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -1259,9 +1264,9 @@ checksums:
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
8LLt89/message: 9a78178dc1874543d35153ae325e2811
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
@@ -1485,9 +1490,9 @@ checksums:
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
@@ -1633,17 +1638,17 @@ checksums:
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
@@ -1915,9 +1920,9 @@ checksums:
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -2011,8 +2016,9 @@ checksums:
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -2043,7 +2049,8 @@ checksums:
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
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
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -2187,9 +2194,13 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
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/origin/0/0: f64cd62b620f454998523af2a4330278
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
@@ -2287,10 +2298,10 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
@@ -2351,11 +2362,11 @@ checksums:
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
@@ -2390,7 +2401,8 @@ checksums:
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
CAL6E9/message: b63448c05270497973ac4407047dae02
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
@@ -2519,6 +2531,9 @@ checksums:
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
@@ -2627,6 +2642,7 @@ checksums:
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
@@ -2738,9 +2754,9 @@ checksums:
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
@@ -2750,9 +2766,9 @@ checksums:
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
@@ -3048,6 +3064,10 @@ checksums:
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c

View File

@@ -1,6 +1,6 @@
import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
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">
<Menu.Item>
<button
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
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"
>
{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

View File

@@ -4,12 +4,13 @@ export interface TierConfig {
plan: WorkspacePlan;
seats: number | null;
unlimitedSeats: boolean;
workspaceSlots: number;
}
const TIER_MAP: Record<number, TierConfig> = {
1: { plan: "team", seats: 5, unlimitedSeats: false },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
};
export function tierConfig(tier: number): TierConfig {

View File

@@ -4,7 +4,6 @@ import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
@@ -110,11 +109,15 @@ export default withRateLimit(
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
const status = license.status === "active" ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
// Ensure subscription slots exist — webhook may have already created them
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license.license_key,
);
if (existing.length === 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
license.license_key,
{
@@ -124,45 +127,18 @@ export default withRateLimit(
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
}
if (!user) {
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
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`);
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
);
}),
);

View File

@@ -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");
}
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");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
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(
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: sub.plan,
status: sub.status,
partnerTier: sub.partnerTier ?? 1,
seats: sub.seats ?? null,
unlimitedSeats: sub.unlimitedSeats,
referenceId: workspace.publicId,
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
referenceId: availableWorkspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
await workspaceRepo.update(db, availableWorkspace.publicId, {
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");
}),
);

View File

@@ -2,17 +2,112 @@ import { createHmac, timingSafeEqual } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import type { dbClient } from "@kan/db/client";
import { createNextApiContext } from "@kan/api/trpc";
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 workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { getActiveSubscriptions } from "@kan/shared/utils";
import type { TierConfig } from "./_utils";
import { tierConfig } from "./_utils";
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) {
const chunks: Buffer[] = [];
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 {
event:
| "purchase"
@@ -93,107 +194,212 @@ export default withApiLogging(
const { db } = await createNextApiContext(req);
switch (event) {
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 "purchase":
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
const status = event === "activate" ? "active" : license_status;
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;
}
case "deactivate": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
const [, allSubs] = await Promise.all([
subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
}),
sub.referenceId
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
: Promise.resolve([]),
]);
if (sub.referenceId) {
const hasActiveSub = getActiveSubscriptions(allSubs).some(
(s) => s.id !== sub.id,
await Promise.all(
allSlots.filter(hasReferenceId).map(async (slot) => {
const siblingSubs = await subscriptionRepo.getByReferenceId(
db,
slot.referenceId,
);
if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
(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;
}
case "upgrade":
case "downgrade": {
const lookupKey = prev_license_key ?? license_key;
const sub = await subscriptionRepo.getByPartnerLicenseKey(
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
lookupKey,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
if (prev_license_key) {
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
if (existing.length === 0) break;
const cfg = tierConfig(tier);
const newCount = cfg.workspaceSlots;
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
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, {
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;
}
case "migrate": {
if (prev_license_key) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
if (!prev_license_key) break;
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
prev_license_key,
);
if (existing.length === 0) break;
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,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}),
),
...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;
}

View File

@@ -67,8 +67,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

View File

@@ -64,8 +64,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

View File

@@ -103,55 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
}
if (storedWorkspaceId !== null) {
const newData = data;
const selectedWorkspace = newData.find(
const selectedWorkspace = data.find(
({ workspace }) => workspace.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
if (pendingWorkspaceId) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
localStorage.removeItem("workspacePublicId");
} else {
return;
}
} else {
// Clear stale workspacePublicId from localStorage
localStorage.removeItem("workspacePublicId");
}
} else {
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role as "admin" | "member" | "guest",
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
setHasLoaded(true);
return;
}
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
} else {
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
}
}, [data, isLoading, isFetching, workspacePublicId, router]);
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole as "admin" | "member" | "guest",
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
setHasLoaded(true);
}, [
data,
isLoading,
isFetching,
workspacePublicId,
pendingWorkspaceId,
router,
]);
return (
<WorkspaceContext.Provider

View File

@@ -1,4 +1,5 @@
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useRef, useState } from "react";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiCheckBadge } from "react-icons/hi2";
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
"x-original-filename": encodeURIComponent(file.name),
},
body: file,
},

View File

@@ -4,6 +4,7 @@ import {
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
@@ -13,6 +14,7 @@ import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function CardDropdown({
cardPublicId,
@@ -20,19 +22,44 @@ export default function CardDropdown({
boardPublicId,
cardCreatedBy,
ticketNumber,
listPublicId,
cardIndex,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
listPublicId?: string;
cardIndex?: number;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const utils = api.useUtils();
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 path =
isTemplate && boardPublicId
@@ -99,6 +126,24 @@ export default function CardDropdown({
<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

View File

@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const {
data: card,
isLoading,
error,
} = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
@@ -340,14 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{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">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
{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">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -356,10 +361,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
card.cardNumber != null &&
card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
listPublicId={card?.list.publicId}
cardIndex={card?.index}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -36,6 +36,17 @@ export default function InvitePage() {
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(
error.message ||
t`Failed to accept invitation. Please try again later, or contact customer support.`,

View File

@@ -26,9 +26,13 @@ import { api } from "~/utils/api";
export function InviteMemberForm({
subscriptions,
unlimitedSeats,
memberCount,
seatLimit,
}: {
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
memberCount: number;
seatLimit: number | null;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -93,6 +97,15 @@ export function InviteMemberForm({
message: t`User is already a member of this workspace`,
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 {
showPopup({
header: t`Error inviting member`,
@@ -285,21 +298,27 @@ export function InviteMemberForm({
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!isPartnerTier &&
!unlimitedSeats && (
(isPartnerTier && seatLimit !== null ? (
<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">
{hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
{!isPartnerTier && (
<p className="mt-1">
{unlimitedSeats
? 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>
)}
<p className="mt-1">
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
@@ -312,7 +331,7 @@ export function InviteMemberForm({
</div>
)}
</div>
)}
) : null)}
</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">

View File

@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
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 Button from "~/components/Button";
@@ -84,6 +88,10 @@ export default function MembersPage() {
const isPaidPlan = isProPlan || isTeamPlan;
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 =
teamSubscription?.seats ??
proSubscription?.seats ??
@@ -402,6 +410,8 @@ export default function MembersPage() {
<InviteMemberForm
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
memberCount={memberCount}
seatLimit={seatLimit}
/>
</Modal>

View File

@@ -40,7 +40,8 @@ export default function WorkspaceNameView() {
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const isLicenseFlow =
!!licenseKeyParam || searchParams.get("partner") === "1";
const { showPopup } = usePopup();
useEffect(() => {
@@ -103,6 +104,7 @@ export default function WorkspaceNameView() {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
void utils.workspace.hasAvailablePartnerSlot.invalidate();
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");

View File

@@ -95,8 +95,28 @@ export function CardModal({
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full p-8">
<div className="mb-6">
<div className="flex w-full items-center justify-between">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
<div className="flex w-full items-start justify-between gap-4">
<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
type="button"
onClick={handleCopyCardLink}
@@ -134,22 +154,6 @@ export function CardModal({
/>
</button>
</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>
{labels.length > 0 && (
<div className="mt-2">

View File

@@ -172,7 +172,7 @@ export default function Avatar({
method: "POST",
headers: {
"Content-Type": blob.type,
"x-original-filename": fileName,
"x-original-filename": encodeURIComponent(fileName),
},
body: blob,
},

View File

@@ -35,6 +35,10 @@
"./utils/permissions": {
"types": "./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",

View File

@@ -1347,7 +1347,7 @@ export const cardRouter = createTRPCRouter({
checklistId: newChecklist.id,
title: item.title,
createdBy: userId,
completed: item.completed ?? false,
completed: false,
});
}
}

View File

@@ -10,13 +10,14 @@ import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import {
generateUID,
getSeatLimit,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
assertCanManageMember,
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);
@@ -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

View File

@@ -2,19 +2,21 @@ import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
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 workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
workspaceDetailSchema,
workspaceListItemSchema,
workspaceUpdateResponseSchema,
workspaceWithBoardsSchema,
} from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -270,12 +272,47 @@ export const workspaceRouter = createTRPCRouter({
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 {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
cardPrefix: result.cardPrefix!,
};
}),
@@ -412,10 +449,21 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
const subs = await subscriptionRepo.getByReferenceId(
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 };
}),
@@ -560,4 +608,38 @@ export const workspaceRouter = createTRPCRouter({
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;
}),
});

View File

@@ -47,6 +47,7 @@ export const cardDetailSchema = z.object({
title: z.string(),
description: z.string().nullable(),
cardNumber: z.number().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
createdBy: z.string().nullable(),
labels: z.array(labelSchema),

View 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,
}),
]);
};

View File

@@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createLogger } from "@kan/logger";
import { generateUID } from "@kan/shared/utils";
const log = createLogger("auth");
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
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) {
return [
socialProvidersPlugin(),
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
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(
db,
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
subscription,
cancellationDetails,
);
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
const workspace = await workspaceRepo.getByPublicId(
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,
});
}
},
onSubscriptionDeleted: async ({ subscription }) => {
await cancelWorkspaceAccess(db, subscription.referenceId);
},
onSubscriptionUpdate: async ({ subscription }) => {
await triggerWorkflow(db, "subscription-updated", subscription);
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
sendMagicLink: async ({ email, url }) => {
try {
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")) {
let inviterName = "";
let workspaceName = "";

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1778617946519,
"tag": "20260512203226_AddPartnerLicenseToSubscription",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1780057951781,
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
"breakpoints": true
}
]
}

View File

@@ -107,7 +107,12 @@ export const create = async (
cardNumber,
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");
@@ -321,8 +326,7 @@ export const bulkCreate = async (
.where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${workspaceId} not found`);
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter;
const start = last - count + 1;
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
dueDate: true,
createdBy: true,
cardNumber: true,
index: true,
},
with: {
labels: {

View File

@@ -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 { MemberRole, MemberStatus } from "@kan/db/schema";
@@ -19,6 +19,27 @@ export const getActiveCount = async (db: dbClient) => {
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 (
db: dbClient,
memberInput: {
@@ -71,14 +92,14 @@ export const getByPublicIdsWithUsers = async (
return db.query.workspaceMembers.findMany({
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
if (workspaceId) {
conditions.push(eq(members.workspaceId, workspaceId));
}
conditions.push(eq(members.status, "active"));
conditions.push(isNullFn(members.deletedAt));
return and(...conditions);
},
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 (
db: dbClient,
args: {

View File

@@ -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 { subscription } from "@kan/db/schema";
@@ -15,6 +15,9 @@ export const updateById = async (
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
stripeSubscriptionId?: string | null;
referenceId?: string | null;
partnerLicenseKey?: string;
partnerTier?: number;
},
) => {
const [result] = await db
@@ -29,6 +32,7 @@ export const updateById = async (
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
referenceId: subscription.referenceId,
});
return result;
@@ -63,6 +67,24 @@ export const updateByStripeSubscriptionId = async (
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 (
db: dbClient,
stripeSubscriptionId: string,
@@ -96,13 +118,63 @@ export const getByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
const result = await db.query.subscription.findFirst({
return await db.query.subscription.findFirst({
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,
partnerLicenseKey: string,
data: {
@@ -111,20 +183,13 @@ export const upsertByPartnerLicenseKey = async (
partnerTier: number;
seats: number | null;
unlimitedSeats: boolean;
referenceId?: string;
},
count: number,
) => {
const [result] = await db
.insert(subscription)
.values({
partnerLicenseKey,
...data,
referenceId: data.referenceId ?? null,
})
.onConflictDoUpdate({
target: subscription.partnerLicenseKey,
set: { ...data, updatedAt: new Date() },
})
.returning();
return result;
const rows = Array.from({ length: count }, () => ({
partnerLicenseKey,
...data,
referenceId: null,
}));
return await db.insert(subscription).values(rows).returning();
};

View File

@@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
name: true,
plan: true,
slug: true,
createdBy: true,
},
where: eq(workspaces.publicId, workspacePublicId),
});
@@ -314,6 +315,16 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
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) => {
return db.query.workspaceMembers.findFirst({
columns: {

View File

@@ -5,42 +5,33 @@ import {
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const subscription = pgTable(
"subscription",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
partnerTier: integer("partnerTier"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => [
uniqueIndex("subscription_partner_license_key_idx").on(
table.partnerLicenseKey,
),
],
).enableRLS();
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
partnerTier: integer("partnerTier"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {

View File

@@ -54,3 +54,14 @@ export const hasUnlimitedSeats = (
const activeSubscriptions = getActiveSubscriptions(subscriptions);
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;
};