@goscribe/server 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ANALYSIS_PROGRESS_SPEC.md +463 -0
- package/PROGRESS_QUICK_REFERENCE.md +239 -0
- package/dist/lib/ai-session.d.ts +20 -9
- package/dist/lib/ai-session.js +316 -80
- package/dist/lib/auth.d.ts +35 -2
- package/dist/lib/auth.js +88 -15
- package/dist/lib/env.d.ts +32 -0
- package/dist/lib/env.js +46 -0
- package/dist/lib/errors.d.ts +33 -0
- package/dist/lib/errors.js +78 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +9 -11
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.js +342 -0
- package/dist/lib/podcast-prompts.d.ts +43 -0
- package/dist/lib/podcast-prompts.js +135 -0
- package/dist/lib/pusher.d.ts +1 -0
- package/dist/lib/pusher.js +14 -2
- package/dist/lib/storage.d.ts +3 -3
- package/dist/lib/storage.js +51 -47
- package/dist/lib/validation.d.ts +51 -0
- package/dist/lib/validation.js +64 -0
- package/dist/routers/_app.d.ts +697 -111
- package/dist/routers/_app.js +5 -0
- package/dist/routers/auth.d.ts +11 -1
- package/dist/routers/chat.d.ts +11 -1
- package/dist/routers/flashcards.d.ts +205 -6
- package/dist/routers/flashcards.js +144 -66
- package/dist/routers/members.d.ts +165 -0
- package/dist/routers/members.js +531 -0
- package/dist/routers/podcast.d.ts +78 -63
- package/dist/routers/podcast.js +330 -393
- package/dist/routers/studyguide.d.ts +11 -1
- package/dist/routers/worksheets.d.ts +124 -13
- package/dist/routers/worksheets.js +123 -50
- package/dist/routers/workspace.d.ts +213 -26
- package/dist/routers/workspace.js +303 -181
- package/dist/server.js +12 -4
- package/dist/services/flashcard-progress.service.d.ts +183 -0
- package/dist/services/flashcard-progress.service.js +383 -0
- package/dist/services/flashcard.service.d.ts +183 -0
- package/dist/services/flashcard.service.js +224 -0
- package/dist/services/podcast-segment-reorder.d.ts +0 -0
- package/dist/services/podcast-segment-reorder.js +107 -0
- package/dist/services/podcast.service.d.ts +0 -0
- package/dist/services/podcast.service.js +326 -0
- package/dist/services/worksheet.service.d.ts +0 -0
- package/dist/services/worksheet.service.js +295 -0
- package/dist/trpc.d.ts +13 -2
- package/dist/trpc.js +55 -6
- package/dist/types/index.d.ts +126 -0
- package/dist/types/index.js +1 -0
- package/package.json +3 -2
- package/prisma/schema.prisma +142 -4
- package/src/lib/ai-session.ts +356 -85
- package/src/lib/auth.ts +113 -19
- package/src/lib/env.ts +59 -0
- package/src/lib/errors.ts +92 -0
- package/src/lib/inference.ts +11 -11
- package/src/lib/logger.ts +405 -0
- package/src/lib/pusher.ts +15 -3
- package/src/lib/storage.ts +56 -51
- package/src/lib/validation.ts +75 -0
- package/src/routers/_app.ts +5 -0
- package/src/routers/chat.ts +2 -23
- package/src/routers/flashcards.ts +108 -24
- package/src/routers/members.ts +586 -0
- package/src/routers/podcast.ts +385 -420
- package/src/routers/worksheets.ts +117 -35
- package/src/routers/workspace.ts +328 -195
- package/src/server.ts +13 -4
- package/src/services/flashcard-progress.service.ts +541 -0
- package/src/trpc.ts +59 -6
- package/src/types/index.ts +165 -0
- package/AUTH_FRONTEND_SPEC.md +0 -21
- package/CHAT_FRONTEND_SPEC.md +0 -474
- package/DATABASE_SETUP.md +0 -165
- package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
- package/PODCAST_FRONTEND_SPEC.md +0 -595
- package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
- package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
- package/WORKSPACE_FRONTEND_SPEC.md +0 -47
- package/test-ai-integration.js +0 -134
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members router for workspace member management
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Get workspace members
|
|
6
|
+
* - Invite new members via email
|
|
7
|
+
* - Accept invitations via UUID
|
|
8
|
+
* - Change member roles
|
|
9
|
+
* - Remove members
|
|
10
|
+
* - Get current user's role
|
|
11
|
+
*/
|
|
12
|
+
export declare const members: import("@trpc/server").TRPCBuiltRouter<{
|
|
13
|
+
ctx: {
|
|
14
|
+
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
15
|
+
session: any;
|
|
16
|
+
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
17
|
+
res: import("express").Response<any, Record<string, any>>;
|
|
18
|
+
cookies: Record<string, string | undefined>;
|
|
19
|
+
};
|
|
20
|
+
meta: object;
|
|
21
|
+
errorShape: {
|
|
22
|
+
data: {
|
|
23
|
+
zodError: string | null;
|
|
24
|
+
code: import("@trpc/server").TRPC_ERROR_CODE_KEY;
|
|
25
|
+
httpStatus: number;
|
|
26
|
+
path?: string;
|
|
27
|
+
stack?: string;
|
|
28
|
+
};
|
|
29
|
+
message: string;
|
|
30
|
+
code: import("@trpc/server").TRPC_ERROR_CODE_NUMBER;
|
|
31
|
+
};
|
|
32
|
+
transformer: true;
|
|
33
|
+
}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
|
|
34
|
+
/**
|
|
35
|
+
* Get all members of a workspace
|
|
36
|
+
*/
|
|
37
|
+
getMembers: import("@trpc/server").TRPCQueryProcedure<{
|
|
38
|
+
input: {
|
|
39
|
+
workspaceId: string;
|
|
40
|
+
};
|
|
41
|
+
output: ({
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
email: string;
|
|
45
|
+
image: string | null;
|
|
46
|
+
role: "admin" | "member";
|
|
47
|
+
joinedAt: Date;
|
|
48
|
+
} | {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
email: string;
|
|
52
|
+
image: string | null;
|
|
53
|
+
role: "owner";
|
|
54
|
+
joinedAt: Date;
|
|
55
|
+
})[];
|
|
56
|
+
meta: object;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Get current user's role in a workspace
|
|
60
|
+
*/
|
|
61
|
+
getCurrentUserRole: import("@trpc/server").TRPCQueryProcedure<{
|
|
62
|
+
input: {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
};
|
|
65
|
+
output: "admin" | "member" | "owner";
|
|
66
|
+
meta: object;
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Invite a new member to the workspace
|
|
70
|
+
*/
|
|
71
|
+
inviteMember: import("@trpc/server").TRPCMutationProcedure<{
|
|
72
|
+
input: {
|
|
73
|
+
workspaceId: string;
|
|
74
|
+
email: string;
|
|
75
|
+
role?: "admin" | "member" | undefined;
|
|
76
|
+
};
|
|
77
|
+
output: {
|
|
78
|
+
invitationId: string;
|
|
79
|
+
token: string;
|
|
80
|
+
email: string;
|
|
81
|
+
role: string;
|
|
82
|
+
expiresAt: Date;
|
|
83
|
+
workspaceTitle: string;
|
|
84
|
+
invitedByName: string | null;
|
|
85
|
+
};
|
|
86
|
+
meta: object;
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Accept an invitation (public endpoint)
|
|
90
|
+
*/
|
|
91
|
+
acceptInvite: import("@trpc/server").TRPCMutationProcedure<{
|
|
92
|
+
input: {
|
|
93
|
+
token: string;
|
|
94
|
+
};
|
|
95
|
+
output: {
|
|
96
|
+
workspaceId: string;
|
|
97
|
+
workspaceTitle: string;
|
|
98
|
+
role: string;
|
|
99
|
+
ownerName: string | null;
|
|
100
|
+
};
|
|
101
|
+
meta: object;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Change a member's role (owner only)
|
|
105
|
+
*/
|
|
106
|
+
changeMemberRole: import("@trpc/server").TRPCMutationProcedure<{
|
|
107
|
+
input: {
|
|
108
|
+
workspaceId: string;
|
|
109
|
+
memberId: string;
|
|
110
|
+
role: "admin" | "member";
|
|
111
|
+
};
|
|
112
|
+
output: {
|
|
113
|
+
memberId: string;
|
|
114
|
+
role: "admin" | "member";
|
|
115
|
+
memberName: string | null;
|
|
116
|
+
message: string;
|
|
117
|
+
};
|
|
118
|
+
meta: object;
|
|
119
|
+
}>;
|
|
120
|
+
/**
|
|
121
|
+
* Remove a member from the workspace (owner only)
|
|
122
|
+
*/
|
|
123
|
+
removeMember: import("@trpc/server").TRPCMutationProcedure<{
|
|
124
|
+
input: {
|
|
125
|
+
workspaceId: string;
|
|
126
|
+
memberId: string;
|
|
127
|
+
};
|
|
128
|
+
output: {
|
|
129
|
+
memberId: string;
|
|
130
|
+
message: string;
|
|
131
|
+
};
|
|
132
|
+
meta: object;
|
|
133
|
+
}>;
|
|
134
|
+
/**
|
|
135
|
+
* Get pending invitations for a workspace (owner only)
|
|
136
|
+
*/
|
|
137
|
+
getPendingInvitations: import("@trpc/server").TRPCQueryProcedure<{
|
|
138
|
+
input: {
|
|
139
|
+
workspaceId: string;
|
|
140
|
+
};
|
|
141
|
+
output: {
|
|
142
|
+
id: string;
|
|
143
|
+
email: string;
|
|
144
|
+
role: string;
|
|
145
|
+
token: string;
|
|
146
|
+
expiresAt: Date;
|
|
147
|
+
createdAt: Date;
|
|
148
|
+
invitedByName: string | null;
|
|
149
|
+
}[];
|
|
150
|
+
meta: object;
|
|
151
|
+
}>;
|
|
152
|
+
/**
|
|
153
|
+
* Cancel a pending invitation (owner only)
|
|
154
|
+
*/
|
|
155
|
+
cancelInvitation: import("@trpc/server").TRPCMutationProcedure<{
|
|
156
|
+
input: {
|
|
157
|
+
invitationId: string;
|
|
158
|
+
};
|
|
159
|
+
output: {
|
|
160
|
+
invitationId: string;
|
|
161
|
+
message: string;
|
|
162
|
+
};
|
|
163
|
+
meta: object;
|
|
164
|
+
}>;
|
|
165
|
+
}>>;
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Members router for workspace member management
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Get workspace members
|
|
10
|
+
* - Invite new members via email
|
|
11
|
+
* - Accept invitations via UUID
|
|
12
|
+
* - Change member roles
|
|
13
|
+
* - Remove members
|
|
14
|
+
* - Get current user's role
|
|
15
|
+
*/
|
|
16
|
+
export const members = router({
|
|
17
|
+
/**
|
|
18
|
+
* Get all members of a workspace
|
|
19
|
+
*/
|
|
20
|
+
getMembers: authedProcedure
|
|
21
|
+
.input(z.object({
|
|
22
|
+
workspaceId: z.string(),
|
|
23
|
+
}))
|
|
24
|
+
.query(async ({ ctx, input }) => {
|
|
25
|
+
// Check if user has access to this workspace
|
|
26
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
27
|
+
where: {
|
|
28
|
+
id: input.workspaceId,
|
|
29
|
+
OR: [
|
|
30
|
+
{ ownerId: ctx.session.user.id },
|
|
31
|
+
{ members: { some: { userId: ctx.session.user.id } } }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
include: {
|
|
35
|
+
owner: {
|
|
36
|
+
select: {
|
|
37
|
+
id: true,
|
|
38
|
+
name: true,
|
|
39
|
+
email: true,
|
|
40
|
+
image: true,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
members: {
|
|
44
|
+
include: {
|
|
45
|
+
user: {
|
|
46
|
+
select: {
|
|
47
|
+
id: true,
|
|
48
|
+
name: true,
|
|
49
|
+
email: true,
|
|
50
|
+
image: true,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (!workspace) {
|
|
58
|
+
throw new TRPCError({
|
|
59
|
+
code: 'NOT_FOUND',
|
|
60
|
+
message: 'Workspace not found or access denied'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Format members with roles
|
|
64
|
+
const members = [
|
|
65
|
+
{
|
|
66
|
+
id: workspace.owner.id,
|
|
67
|
+
name: workspace.owner.name || 'Unknown',
|
|
68
|
+
email: workspace.owner.email || '',
|
|
69
|
+
image: workspace.owner.image,
|
|
70
|
+
role: 'owner',
|
|
71
|
+
joinedAt: workspace.createdAt,
|
|
72
|
+
},
|
|
73
|
+
...workspace.members.map(membership => ({
|
|
74
|
+
id: membership.user.id,
|
|
75
|
+
name: membership.user.name || 'Unknown',
|
|
76
|
+
email: membership.user.email || '',
|
|
77
|
+
image: membership.user.image,
|
|
78
|
+
role: membership.role,
|
|
79
|
+
joinedAt: membership.joinedAt,
|
|
80
|
+
}))
|
|
81
|
+
];
|
|
82
|
+
return members;
|
|
83
|
+
}),
|
|
84
|
+
/**
|
|
85
|
+
* Get current user's role in a workspace
|
|
86
|
+
*/
|
|
87
|
+
getCurrentUserRole: authedProcedure
|
|
88
|
+
.input(z.object({
|
|
89
|
+
workspaceId: z.string(),
|
|
90
|
+
}))
|
|
91
|
+
.query(async ({ ctx, input }) => {
|
|
92
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
93
|
+
where: { id: input.workspaceId },
|
|
94
|
+
select: {
|
|
95
|
+
ownerId: true,
|
|
96
|
+
members: {
|
|
97
|
+
where: { userId: ctx.session.user.id },
|
|
98
|
+
select: { role: true }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
if (!workspace) {
|
|
103
|
+
throw new TRPCError({
|
|
104
|
+
code: 'NOT_FOUND',
|
|
105
|
+
message: 'Workspace not found'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (workspace.ownerId === ctx.session.user.id) {
|
|
109
|
+
return 'owner';
|
|
110
|
+
}
|
|
111
|
+
if (workspace.members.length > 0) {
|
|
112
|
+
return workspace.members[0].role;
|
|
113
|
+
}
|
|
114
|
+
throw new TRPCError({
|
|
115
|
+
code: 'FORBIDDEN',
|
|
116
|
+
message: 'Access denied to this workspace'
|
|
117
|
+
});
|
|
118
|
+
}),
|
|
119
|
+
/**
|
|
120
|
+
* Invite a new member to the workspace
|
|
121
|
+
*/
|
|
122
|
+
inviteMember: authedProcedure
|
|
123
|
+
.input(z.object({
|
|
124
|
+
workspaceId: z.string(),
|
|
125
|
+
email: z.string().email(),
|
|
126
|
+
role: z.enum(['admin', 'member']).default('member'),
|
|
127
|
+
}))
|
|
128
|
+
.mutation(async ({ ctx, input }) => {
|
|
129
|
+
// Check if user is owner or admin of the workspace
|
|
130
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
131
|
+
where: {
|
|
132
|
+
id: input.workspaceId,
|
|
133
|
+
ownerId: ctx.session.user.id // Only owners can invite for now
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
if (!workspace) {
|
|
137
|
+
throw new TRPCError({
|
|
138
|
+
code: 'NOT_FOUND',
|
|
139
|
+
message: 'Workspace not found or insufficient permissions'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Check if user is already a member
|
|
143
|
+
const existingMember = await ctx.db.user.findFirst({
|
|
144
|
+
where: {
|
|
145
|
+
email: input.email,
|
|
146
|
+
OR: [
|
|
147
|
+
{ id: workspace.ownerId },
|
|
148
|
+
{ workspaceMemberships: { some: { workspaceId: input.workspaceId } } }
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (existingMember) {
|
|
153
|
+
throw new TRPCError({
|
|
154
|
+
code: 'BAD_REQUEST',
|
|
155
|
+
message: 'User is already a member of this workspace'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Check if there's already a pending invitation
|
|
159
|
+
const existingInvitation = await ctx.db.workspaceInvitation.findFirst({
|
|
160
|
+
where: {
|
|
161
|
+
workspaceId: input.workspaceId,
|
|
162
|
+
email: input.email,
|
|
163
|
+
acceptedAt: null,
|
|
164
|
+
expiresAt: { gt: new Date() }
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
if (existingInvitation) {
|
|
168
|
+
throw new TRPCError({
|
|
169
|
+
code: 'BAD_REQUEST',
|
|
170
|
+
message: 'Invitation already sent to this email'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// Create invitation
|
|
174
|
+
const invitation = await ctx.db.workspaceInvitation.create({
|
|
175
|
+
data: {
|
|
176
|
+
workspaceId: input.workspaceId,
|
|
177
|
+
email: input.email,
|
|
178
|
+
role: input.role,
|
|
179
|
+
invitedById: ctx.session.user.id,
|
|
180
|
+
},
|
|
181
|
+
include: {
|
|
182
|
+
workspace: {
|
|
183
|
+
select: {
|
|
184
|
+
title: true,
|
|
185
|
+
owner: {
|
|
186
|
+
select: {
|
|
187
|
+
name: true,
|
|
188
|
+
email: true,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
logger.info(`🎫 Invitation created for ${input.email} to workspace ${input.workspaceId} with role ${input.role}`, 'WORKSPACE', {
|
|
196
|
+
invitationId: invitation.id,
|
|
197
|
+
workspaceId: input.workspaceId,
|
|
198
|
+
email: input.email,
|
|
199
|
+
role: input.role,
|
|
200
|
+
invitedBy: ctx.session.user.id
|
|
201
|
+
});
|
|
202
|
+
// TODO: Send email notification here
|
|
203
|
+
// await sendInvitationEmail(invitation);
|
|
204
|
+
return {
|
|
205
|
+
invitationId: invitation.id,
|
|
206
|
+
token: invitation.token,
|
|
207
|
+
email: invitation.email,
|
|
208
|
+
role: invitation.role,
|
|
209
|
+
expiresAt: invitation.expiresAt,
|
|
210
|
+
workspaceTitle: invitation.workspace.title,
|
|
211
|
+
invitedByName: invitation.workspace.owner.name || invitation.workspace.owner.email,
|
|
212
|
+
};
|
|
213
|
+
}),
|
|
214
|
+
/**
|
|
215
|
+
* Accept an invitation (public endpoint)
|
|
216
|
+
*/
|
|
217
|
+
acceptInvite: publicProcedure
|
|
218
|
+
.input(z.object({
|
|
219
|
+
token: z.string(),
|
|
220
|
+
}))
|
|
221
|
+
.mutation(async ({ ctx, input }) => {
|
|
222
|
+
// Find the invitation
|
|
223
|
+
const invitation = await ctx.db.workspaceInvitation.findFirst({
|
|
224
|
+
where: {
|
|
225
|
+
token: input.token,
|
|
226
|
+
acceptedAt: null,
|
|
227
|
+
expiresAt: { gt: new Date() }
|
|
228
|
+
},
|
|
229
|
+
include: {
|
|
230
|
+
workspace: {
|
|
231
|
+
select: {
|
|
232
|
+
id: true,
|
|
233
|
+
title: true,
|
|
234
|
+
owner: {
|
|
235
|
+
select: {
|
|
236
|
+
name: true,
|
|
237
|
+
email: true,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
if (!invitation) {
|
|
245
|
+
throw new TRPCError({
|
|
246
|
+
code: 'NOT_FOUND',
|
|
247
|
+
message: 'Invalid or expired invitation'
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// Check if user is authenticated
|
|
251
|
+
if (!ctx.session?.user) {
|
|
252
|
+
throw new TRPCError({
|
|
253
|
+
code: 'UNAUTHORIZED',
|
|
254
|
+
message: 'Please log in to accept this invitation'
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// Check if the email matches the user's email
|
|
258
|
+
if (ctx.session.user.email !== invitation.email) {
|
|
259
|
+
throw new TRPCError({
|
|
260
|
+
code: 'BAD_REQUEST',
|
|
261
|
+
message: 'This invitation was sent to a different email address'
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
// Check if user is already a member
|
|
265
|
+
const isAlreadyMember = await ctx.db.workspace.findFirst({
|
|
266
|
+
where: {
|
|
267
|
+
id: invitation.workspaceId,
|
|
268
|
+
OR: [
|
|
269
|
+
{ ownerId: ctx.session.user.id },
|
|
270
|
+
{ members: { some: { userId: ctx.session.user.id } } }
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
if (isAlreadyMember) {
|
|
275
|
+
// Mark invitation as accepted even if already a member
|
|
276
|
+
await ctx.db.workspaceInvitation.update({
|
|
277
|
+
where: { id: invitation.id },
|
|
278
|
+
data: { acceptedAt: new Date() }
|
|
279
|
+
});
|
|
280
|
+
throw new TRPCError({
|
|
281
|
+
code: 'BAD_REQUEST',
|
|
282
|
+
message: 'You are already a member of this workspace'
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Add user to workspace with proper role
|
|
286
|
+
await ctx.db.workspaceMember.create({
|
|
287
|
+
data: {
|
|
288
|
+
workspaceId: invitation.workspaceId,
|
|
289
|
+
userId: ctx.session.user.id,
|
|
290
|
+
role: invitation.role,
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// Mark invitation as accepted
|
|
294
|
+
await ctx.db.workspaceInvitation.update({
|
|
295
|
+
where: { id: invitation.id },
|
|
296
|
+
data: { acceptedAt: new Date() }
|
|
297
|
+
});
|
|
298
|
+
logger.info(`✅ Invitation accepted by ${ctx.session.user.id} for workspace ${invitation.workspaceId}`, 'WORKSPACE', {
|
|
299
|
+
invitationId: invitation.id,
|
|
300
|
+
workspaceId: invitation.workspaceId,
|
|
301
|
+
userId: ctx.session.user.id,
|
|
302
|
+
email: invitation.email
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
workspaceId: invitation.workspaceId,
|
|
306
|
+
workspaceTitle: invitation.workspace.title,
|
|
307
|
+
role: invitation.role,
|
|
308
|
+
ownerName: invitation.workspace.owner.name || invitation.workspace.owner.email,
|
|
309
|
+
};
|
|
310
|
+
}),
|
|
311
|
+
/**
|
|
312
|
+
* Change a member's role (owner only)
|
|
313
|
+
*/
|
|
314
|
+
changeMemberRole: authedProcedure
|
|
315
|
+
.input(z.object({
|
|
316
|
+
workspaceId: z.string(),
|
|
317
|
+
memberId: z.string(),
|
|
318
|
+
role: z.enum(['admin', 'member']),
|
|
319
|
+
}))
|
|
320
|
+
.mutation(async ({ ctx, input }) => {
|
|
321
|
+
// Check if user is owner of the workspace
|
|
322
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
323
|
+
where: {
|
|
324
|
+
id: input.workspaceId,
|
|
325
|
+
ownerId: ctx.session.user.id
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
if (!workspace) {
|
|
329
|
+
throw new TRPCError({
|
|
330
|
+
code: 'NOT_FOUND',
|
|
331
|
+
message: 'Workspace not found or insufficient permissions'
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// Check if member exists and is not the owner
|
|
335
|
+
if (input.memberId === workspace.ownerId) {
|
|
336
|
+
throw new TRPCError({
|
|
337
|
+
code: 'BAD_REQUEST',
|
|
338
|
+
message: 'Cannot change owner role'
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const member = await ctx.db.workspaceMember.findFirst({
|
|
342
|
+
where: {
|
|
343
|
+
workspaceId: input.workspaceId,
|
|
344
|
+
userId: input.memberId
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
if (!member) {
|
|
348
|
+
throw new TRPCError({
|
|
349
|
+
code: 'NOT_FOUND',
|
|
350
|
+
message: 'Member not found in this workspace'
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
// Update the member's role
|
|
354
|
+
const updatedMember = await ctx.db.workspaceMember.update({
|
|
355
|
+
where: { id: member.id },
|
|
356
|
+
data: { role: input.role },
|
|
357
|
+
include: {
|
|
358
|
+
user: {
|
|
359
|
+
select: {
|
|
360
|
+
id: true,
|
|
361
|
+
name: true,
|
|
362
|
+
email: true,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
logger.info(`🔄 Member role changed for ${input.memberId} from ${member.role} to ${input.role} in workspace ${input.workspaceId}`, 'WORKSPACE', {
|
|
368
|
+
workspaceId: input.workspaceId,
|
|
369
|
+
memberId: input.memberId,
|
|
370
|
+
oldRole: member.role,
|
|
371
|
+
newRole: input.role,
|
|
372
|
+
changedBy: ctx.session.user.id
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
memberId: input.memberId,
|
|
376
|
+
role: input.role,
|
|
377
|
+
memberName: updatedMember.user.name || updatedMember.user.email,
|
|
378
|
+
message: 'Role changed successfully'
|
|
379
|
+
};
|
|
380
|
+
}),
|
|
381
|
+
/**
|
|
382
|
+
* Remove a member from the workspace (owner only)
|
|
383
|
+
*/
|
|
384
|
+
removeMember: authedProcedure
|
|
385
|
+
.input(z.object({
|
|
386
|
+
workspaceId: z.string(),
|
|
387
|
+
memberId: z.string(),
|
|
388
|
+
}))
|
|
389
|
+
.mutation(async ({ ctx, input }) => {
|
|
390
|
+
// Check if user is owner of the workspace
|
|
391
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
392
|
+
where: {
|
|
393
|
+
id: input.workspaceId,
|
|
394
|
+
ownerId: ctx.session.user.id
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
if (!workspace) {
|
|
398
|
+
throw new TRPCError({
|
|
399
|
+
code: 'NOT_FOUND',
|
|
400
|
+
message: 'Workspace not found or insufficient permissions'
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// Check if trying to remove the owner
|
|
404
|
+
if (input.memberId === workspace.ownerId) {
|
|
405
|
+
throw new TRPCError({
|
|
406
|
+
code: 'BAD_REQUEST',
|
|
407
|
+
message: 'Cannot remove workspace owner'
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Check if member exists
|
|
411
|
+
const member = await ctx.db.workspaceMember.findFirst({
|
|
412
|
+
where: {
|
|
413
|
+
workspaceId: input.workspaceId,
|
|
414
|
+
userId: input.memberId
|
|
415
|
+
},
|
|
416
|
+
include: {
|
|
417
|
+
user: {
|
|
418
|
+
select: {
|
|
419
|
+
name: true,
|
|
420
|
+
email: true,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
if (!member) {
|
|
426
|
+
throw new TRPCError({
|
|
427
|
+
code: 'NOT_FOUND',
|
|
428
|
+
message: 'Member not found in this workspace'
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// Remove member from workspace
|
|
432
|
+
await ctx.db.workspaceMember.delete({
|
|
433
|
+
where: { id: member.id }
|
|
434
|
+
});
|
|
435
|
+
logger.info(`🗑️ Member ${input.memberId} removed from workspace ${input.workspaceId}`, 'WORKSPACE', {
|
|
436
|
+
workspaceId: input.workspaceId,
|
|
437
|
+
memberId: input.memberId,
|
|
438
|
+
removedBy: ctx.session.user.id
|
|
439
|
+
});
|
|
440
|
+
return {
|
|
441
|
+
memberId: input.memberId,
|
|
442
|
+
message: 'Member removed successfully'
|
|
443
|
+
};
|
|
444
|
+
}),
|
|
445
|
+
/**
|
|
446
|
+
* Get pending invitations for a workspace (owner only)
|
|
447
|
+
*/
|
|
448
|
+
getPendingInvitations: authedProcedure
|
|
449
|
+
.input(z.object({
|
|
450
|
+
workspaceId: z.string(),
|
|
451
|
+
}))
|
|
452
|
+
.query(async ({ ctx, input }) => {
|
|
453
|
+
// Check if user is owner of the workspace
|
|
454
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
455
|
+
where: {
|
|
456
|
+
id: input.workspaceId,
|
|
457
|
+
ownerId: ctx.session.user.id
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
if (!workspace) {
|
|
461
|
+
throw new TRPCError({
|
|
462
|
+
code: 'NOT_FOUND',
|
|
463
|
+
message: 'Workspace not found or insufficient permissions'
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const invitations = await ctx.db.workspaceInvitation.findMany({
|
|
467
|
+
where: {
|
|
468
|
+
workspaceId: input.workspaceId,
|
|
469
|
+
acceptedAt: null,
|
|
470
|
+
expiresAt: { gt: new Date() }
|
|
471
|
+
},
|
|
472
|
+
include: {
|
|
473
|
+
invitedBy: {
|
|
474
|
+
select: {
|
|
475
|
+
name: true,
|
|
476
|
+
email: true,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
orderBy: { createdAt: 'desc' }
|
|
481
|
+
});
|
|
482
|
+
return invitations.map(invitation => ({
|
|
483
|
+
id: invitation.id,
|
|
484
|
+
email: invitation.email,
|
|
485
|
+
role: invitation.role,
|
|
486
|
+
token: invitation.token,
|
|
487
|
+
expiresAt: invitation.expiresAt,
|
|
488
|
+
createdAt: invitation.createdAt,
|
|
489
|
+
invitedByName: invitation.invitedBy.name || invitation.invitedBy.email,
|
|
490
|
+
}));
|
|
491
|
+
}),
|
|
492
|
+
/**
|
|
493
|
+
* Cancel a pending invitation (owner only)
|
|
494
|
+
*/
|
|
495
|
+
cancelInvitation: authedProcedure
|
|
496
|
+
.input(z.object({
|
|
497
|
+
invitationId: z.string(),
|
|
498
|
+
}))
|
|
499
|
+
.mutation(async ({ ctx, input }) => {
|
|
500
|
+
// Check if user is owner of the workspace
|
|
501
|
+
const invitation = await ctx.db.workspaceInvitation.findFirst({
|
|
502
|
+
where: {
|
|
503
|
+
id: input.invitationId,
|
|
504
|
+
acceptedAt: null,
|
|
505
|
+
workspace: {
|
|
506
|
+
ownerId: ctx.session.user.id
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
if (!invitation) {
|
|
511
|
+
throw new TRPCError({
|
|
512
|
+
code: 'NOT_FOUND',
|
|
513
|
+
message: 'Invitation not found or insufficient permissions'
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Delete the invitation
|
|
517
|
+
await ctx.db.workspaceInvitation.delete({
|
|
518
|
+
where: { id: input.invitationId }
|
|
519
|
+
});
|
|
520
|
+
logger.info(`❌ Invitation cancelled for ${invitation.email} to workspace ${invitation.workspaceId}`, 'WORKSPACE', {
|
|
521
|
+
invitationId: input.invitationId,
|
|
522
|
+
workspaceId: invitation.workspaceId,
|
|
523
|
+
email: invitation.email,
|
|
524
|
+
cancelledBy: ctx.session.user.id
|
|
525
|
+
});
|
|
526
|
+
return {
|
|
527
|
+
invitationId: input.invitationId,
|
|
528
|
+
message: 'Invitation cancelled successfully'
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
});
|