kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq, gt } from "drizzle-orm";
2import db from "../database";
3import { invitationTable, userTable, workspaceTable } from "../database/schema";
4
5type RegistrationCheckResult = {
6 allowed: boolean;
7 reason: string;
8 invitation?: {
9 id: string;
10 email: string;
11 workspaceId: string;
12 workspaceName: string;
13 inviterName: string;
14 expiresAt: Date;
15 status: string;
16 };
17};
18
19export async function checkRegistrationAllowed(
20 email?: string,
21 invitationId?: string,
22): Promise<RegistrationCheckResult> {
23 const isRegistrationDisabled = process.env.DISABLE_REGISTRATION === "true";
24
25 if (!isRegistrationDisabled) {
26 return {
27 allowed: true,
28 reason: "Registration is enabled",
29 };
30 }
31
32 if (!invitationId && !email) {
33 return {
34 allowed: false,
35 reason:
36 "Registration is currently disabled. Please contact your administrator for an invitation.",
37 };
38 }
39
40 const invitation = await findValidInvitation(email, invitationId);
41
42 if (!invitation) {
43 return {
44 allowed: false,
45 reason:
46 "Registration is currently disabled. You need a valid invitation to create an account.",
47 };
48 }
49
50 return {
51 allowed: true,
52 reason: "Valid invitation found",
53 invitation,
54 };
55}
56
57async function findValidInvitation(
58 email?: string,
59 invitationId?: string,
60): Promise<RegistrationCheckResult["invitation"] | null> {
61 const now = new Date();
62
63 const conditions = [
64 eq(invitationTable.status, "pending"),
65 gt(invitationTable.expiresAt, now),
66 ];
67
68 if (invitationId) {
69 conditions.push(eq(invitationTable.id, invitationId));
70 }
71
72 if (email) {
73 conditions.push(eq(invitationTable.email, email.toLowerCase()));
74 }
75
76 if (!invitationId && !email) {
77 return null;
78 }
79
80 const result = await db
81 .select({
82 id: invitationTable.id,
83 email: invitationTable.email,
84 workspaceId: invitationTable.workspaceId,
85 workspaceName: workspaceTable.name,
86 inviterName: userTable.name,
87 expiresAt: invitationTable.expiresAt,
88 status: invitationTable.status,
89 })
90 .from(invitationTable)
91 .innerJoin(
92 workspaceTable,
93 eq(invitationTable.workspaceId, workspaceTable.id),
94 )
95 .innerJoin(userTable, eq(invitationTable.inviterId, userTable.id))
96 .where(and(...conditions))
97 .limit(1);
98
99 const row = result[0];
100 if (!row) {
101 return null;
102 }
103
104 return row;
105}
106
107type InvitationDetails = {
108 id: string;
109 email: string;
110 workspaceName: string;
111 inviterName: string;
112 expiresAt: Date;
113 status: string;
114 expired: boolean;
115};
116
117type InvitationDetailsResult = {
118 valid: boolean;
119 invitation?: InvitationDetails;
120 error?: string;
121};
122
123export async function getInvitationDetails(
124 invitationId: string,
125): Promise<InvitationDetailsResult> {
126 const now = new Date();
127
128 const result = await db
129 .select({
130 id: invitationTable.id,
131 email: invitationTable.email,
132 workspaceName: workspaceTable.name,
133 inviterName: userTable.name,
134 expiresAt: invitationTable.expiresAt,
135 status: invitationTable.status,
136 })
137 .from(invitationTable)
138 .innerJoin(
139 workspaceTable,
140 eq(invitationTable.workspaceId, workspaceTable.id),
141 )
142 .innerJoin(userTable, eq(invitationTable.inviterId, userTable.id))
143 .where(eq(invitationTable.id, invitationId))
144 .limit(1);
145
146 const row = result[0];
147 if (!row) {
148 return {
149 valid: false,
150 error: "Invitation not found",
151 };
152 }
153
154 const expired = row.expiresAt < now;
155 const isAccepted = row.status === "accepted";
156 const isCanceled = row.status === "canceled";
157
158 const baseInvitation: InvitationDetails = {
159 id: row.id,
160 email: row.email,
161 workspaceName: row.workspaceName,
162 inviterName: row.inviterName,
163 expiresAt: row.expiresAt,
164 status: row.status,
165 expired,
166 };
167
168 if (isAccepted) {
169 return {
170 valid: false,
171 error: "This invitation has already been accepted",
172 };
173 }
174
175 if (isCanceled) {
176 return {
177 valid: false,
178 error: "This invitation has been canceled",
179 };
180 }
181
182 if (expired) {
183 return {
184 valid: false,
185 invitation: baseInvitation,
186 error: "This invitation has expired",
187 };
188 }
189
190 return {
191 valid: true,
192 invitation: baseInvitation,
193 };
194}
195
196export async function getUserPendingInvitations(userEmail: string) {
197 const now = new Date();
198
199 const result = await db
200 .select({
201 id: invitationTable.id,
202 email: invitationTable.email,
203 workspaceId: invitationTable.workspaceId,
204 workspaceName: workspaceTable.name,
205 inviterName: userTable.name,
206 expiresAt: invitationTable.expiresAt,
207 createdAt: invitationTable.createdAt,
208 status: invitationTable.status,
209 })
210 .from(invitationTable)
211 .innerJoin(
212 workspaceTable,
213 eq(invitationTable.workspaceId, workspaceTable.id),
214 )
215 .innerJoin(userTable, eq(invitationTable.inviterId, userTable.id))
216 .where(
217 and(
218 eq(invitationTable.email, userEmail.toLowerCase()),
219 eq(invitationTable.status, "pending"),
220 gt(invitationTable.expiresAt, now),
221 ),
222 )
223 .orderBy(invitationTable.createdAt);
224
225 return result;
226}