lsh-framework 1.3.2 → 1.4.1
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/.env.example +43 -3
- package/README.md +25 -4
- package/dist/cli.js +6 -0
- package/dist/commands/config.js +240 -0
- package/dist/daemon/saas-api-routes.js +778 -0
- package/dist/daemon/saas-api-server.js +225 -0
- package/dist/lib/config-manager.js +321 -0
- package/dist/lib/database-persistence.js +75 -3
- package/dist/lib/env-validator.js +17 -0
- package/dist/lib/local-storage-adapter.js +493 -0
- package/dist/lib/saas-audit.js +213 -0
- package/dist/lib/saas-auth.js +427 -0
- package/dist/lib/saas-billing.js +402 -0
- package/dist/lib/saas-email.js +402 -0
- package/dist/lib/saas-encryption.js +220 -0
- package/dist/lib/saas-organizations.js +592 -0
- package/dist/lib/saas-secrets.js +378 -0
- package/dist/lib/saas-types.js +108 -0
- package/dist/lib/supabase-client.js +77 -11
- package/package.json +13 -2
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS Organization & Team Management Service
|
|
3
|
+
* Handles organizations, teams, members, and RBAC
|
|
4
|
+
*/
|
|
5
|
+
import { getSupabaseClient } from './supabase-client.js';
|
|
6
|
+
import { auditLogger } from './saas-audit.js';
|
|
7
|
+
/**
|
|
8
|
+
* Generate URL-friendly slug from name
|
|
9
|
+
*/
|
|
10
|
+
function slugify(name) {
|
|
11
|
+
let result = name
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/[^\w\s-]/g, '')
|
|
15
|
+
.replace(/[\s_-]+/g, '-');
|
|
16
|
+
// Remove leading and trailing dashes without vulnerable regex
|
|
17
|
+
while (result.startsWith('-')) {
|
|
18
|
+
result = result.slice(1);
|
|
19
|
+
}
|
|
20
|
+
while (result.endsWith('-')) {
|
|
21
|
+
result = result.slice(0, -1);
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Organization Service
|
|
27
|
+
*/
|
|
28
|
+
export class OrganizationService {
|
|
29
|
+
supabase = getSupabaseClient();
|
|
30
|
+
/**
|
|
31
|
+
* Create a new organization
|
|
32
|
+
*/
|
|
33
|
+
async createOrganization(input) {
|
|
34
|
+
const slug = input.slug || slugify(input.name);
|
|
35
|
+
// Check if slug already exists
|
|
36
|
+
const { data: existing } = await this.supabase
|
|
37
|
+
.from('organizations')
|
|
38
|
+
.select('id')
|
|
39
|
+
.eq('slug', slug)
|
|
40
|
+
.single();
|
|
41
|
+
if (existing) {
|
|
42
|
+
throw new Error('ALREADY_EXISTS: Organization slug already taken');
|
|
43
|
+
}
|
|
44
|
+
// Create organization
|
|
45
|
+
const { data: org, error } = await this.supabase
|
|
46
|
+
.from('organizations')
|
|
47
|
+
.insert({
|
|
48
|
+
name: input.name,
|
|
49
|
+
slug,
|
|
50
|
+
subscription_tier: 'free',
|
|
51
|
+
subscription_status: 'active',
|
|
52
|
+
})
|
|
53
|
+
.select()
|
|
54
|
+
.single();
|
|
55
|
+
if (error) {
|
|
56
|
+
throw new Error(`Failed to create organization: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
// Add owner as first member
|
|
59
|
+
await this.addMember({
|
|
60
|
+
organizationId: org.id,
|
|
61
|
+
userId: input.ownerId,
|
|
62
|
+
role: 'owner',
|
|
63
|
+
});
|
|
64
|
+
// Log audit event
|
|
65
|
+
await auditLogger.log({
|
|
66
|
+
organizationId: org.id,
|
|
67
|
+
userId: input.ownerId,
|
|
68
|
+
action: 'organization.create',
|
|
69
|
+
resourceType: 'organization',
|
|
70
|
+
resourceId: org.id,
|
|
71
|
+
newValue: { name: org.name, slug: org.slug },
|
|
72
|
+
});
|
|
73
|
+
return this.mapDbOrgToOrg(org);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get organization by ID
|
|
77
|
+
*/
|
|
78
|
+
async getOrganizationById(id) {
|
|
79
|
+
const { data: org, error } = await this.supabase
|
|
80
|
+
.from('organizations')
|
|
81
|
+
.select('*')
|
|
82
|
+
.eq('id', id)
|
|
83
|
+
.is('deleted_at', null)
|
|
84
|
+
.single();
|
|
85
|
+
if (error || !org) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return this.mapDbOrgToOrg(org);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get organization by slug
|
|
92
|
+
*/
|
|
93
|
+
async getOrganizationBySlug(slug) {
|
|
94
|
+
const { data: org, error } = await this.supabase
|
|
95
|
+
.from('organizations')
|
|
96
|
+
.select('*')
|
|
97
|
+
.eq('slug', slug)
|
|
98
|
+
.is('deleted_at', null)
|
|
99
|
+
.single();
|
|
100
|
+
if (error || !org) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return this.mapDbOrgToOrg(org);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Update organization
|
|
107
|
+
*/
|
|
108
|
+
async updateOrganization(id, updates) {
|
|
109
|
+
const updateData = {};
|
|
110
|
+
if (updates.name) {
|
|
111
|
+
updateData.name = updates.name;
|
|
112
|
+
}
|
|
113
|
+
if (updates.settings) {
|
|
114
|
+
updateData.settings = updates.settings;
|
|
115
|
+
}
|
|
116
|
+
const { data: org, error } = await this.supabase
|
|
117
|
+
.from('organizations')
|
|
118
|
+
.update(updateData)
|
|
119
|
+
.eq('id', id)
|
|
120
|
+
.select()
|
|
121
|
+
.single();
|
|
122
|
+
if (error) {
|
|
123
|
+
throw new Error(`Failed to update organization: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
return this.mapDbOrgToOrg(org);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Delete organization (soft delete)
|
|
129
|
+
*/
|
|
130
|
+
async deleteOrganization(id, deletedBy) {
|
|
131
|
+
const { error } = await this.supabase
|
|
132
|
+
.from('organizations')
|
|
133
|
+
.update({ deleted_at: new Date().toISOString() })
|
|
134
|
+
.eq('id', id);
|
|
135
|
+
if (error) {
|
|
136
|
+
throw new Error(`Failed to delete organization: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
await auditLogger.log({
|
|
139
|
+
organizationId: id,
|
|
140
|
+
userId: deletedBy,
|
|
141
|
+
action: 'organization.delete',
|
|
142
|
+
resourceType: 'organization',
|
|
143
|
+
resourceId: id,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Add member to organization
|
|
148
|
+
*/
|
|
149
|
+
async addMember(params) {
|
|
150
|
+
// Check if already a member
|
|
151
|
+
const { data: existing } = await this.supabase
|
|
152
|
+
.from('organization_members')
|
|
153
|
+
.select('id')
|
|
154
|
+
.eq('organization_id', params.organizationId)
|
|
155
|
+
.eq('user_id', params.userId)
|
|
156
|
+
.single();
|
|
157
|
+
if (existing) {
|
|
158
|
+
throw new Error('ALREADY_EXISTS: User is already a member');
|
|
159
|
+
}
|
|
160
|
+
// Check tier limits
|
|
161
|
+
await this.checkMemberLimit(params.organizationId);
|
|
162
|
+
const { data: member, error } = await this.supabase
|
|
163
|
+
.from('organization_members')
|
|
164
|
+
.insert({
|
|
165
|
+
organization_id: params.organizationId,
|
|
166
|
+
user_id: params.userId,
|
|
167
|
+
role: params.role,
|
|
168
|
+
invited_by: params.invitedBy || null,
|
|
169
|
+
accepted_at: new Date().toISOString(),
|
|
170
|
+
})
|
|
171
|
+
.select()
|
|
172
|
+
.single();
|
|
173
|
+
if (error) {
|
|
174
|
+
throw new Error(`Failed to add member: ${error.message}`);
|
|
175
|
+
}
|
|
176
|
+
await auditLogger.log({
|
|
177
|
+
organizationId: params.organizationId,
|
|
178
|
+
userId: params.invitedBy || params.userId,
|
|
179
|
+
action: 'member.accept',
|
|
180
|
+
resourceType: 'user',
|
|
181
|
+
resourceId: params.userId,
|
|
182
|
+
newValue: { role: params.role },
|
|
183
|
+
});
|
|
184
|
+
return this.mapDbMemberToMember(member);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Update member role
|
|
188
|
+
*/
|
|
189
|
+
async updateMemberRole(organizationId, userId, newRole, updatedBy) {
|
|
190
|
+
const { data: member, error } = await this.supabase
|
|
191
|
+
.from('organization_members')
|
|
192
|
+
.update({ role: newRole })
|
|
193
|
+
.eq('organization_id', organizationId)
|
|
194
|
+
.eq('user_id', userId)
|
|
195
|
+
.select()
|
|
196
|
+
.single();
|
|
197
|
+
if (error) {
|
|
198
|
+
throw new Error(`Failed to update member role: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
await auditLogger.log({
|
|
201
|
+
organizationId,
|
|
202
|
+
userId: updatedBy,
|
|
203
|
+
action: 'member.role_change',
|
|
204
|
+
resourceType: 'user',
|
|
205
|
+
resourceId: userId,
|
|
206
|
+
newValue: { role: newRole },
|
|
207
|
+
});
|
|
208
|
+
return this.mapDbMemberToMember(member);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Remove member from organization
|
|
212
|
+
*/
|
|
213
|
+
async removeMember(organizationId, userId, removedBy) {
|
|
214
|
+
const { error } = await this.supabase
|
|
215
|
+
.from('organization_members')
|
|
216
|
+
.delete()
|
|
217
|
+
.eq('organization_id', organizationId)
|
|
218
|
+
.eq('user_id', userId);
|
|
219
|
+
if (error) {
|
|
220
|
+
throw new Error(`Failed to remove member: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
await auditLogger.log({
|
|
223
|
+
organizationId,
|
|
224
|
+
userId: removedBy,
|
|
225
|
+
action: 'member.remove',
|
|
226
|
+
resourceType: 'user',
|
|
227
|
+
resourceId: userId,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get organization members
|
|
232
|
+
*/
|
|
233
|
+
async getOrganizationMembers(organizationId) {
|
|
234
|
+
const { data, error } = await this.supabase
|
|
235
|
+
.from('organization_members_detailed')
|
|
236
|
+
.select('*')
|
|
237
|
+
.eq('organization_id', organizationId);
|
|
238
|
+
if (error) {
|
|
239
|
+
throw new Error(`Failed to get members: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
return (data || []).map(this.mapDbMemberDetailedToMemberDetailed);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get user's role in organization
|
|
245
|
+
*/
|
|
246
|
+
async getUserRole(organizationId, userId) {
|
|
247
|
+
const { data, error } = await this.supabase
|
|
248
|
+
.from('organization_members')
|
|
249
|
+
.select('role')
|
|
250
|
+
.eq('organization_id', organizationId)
|
|
251
|
+
.eq('user_id', userId)
|
|
252
|
+
.single();
|
|
253
|
+
if (error || !data) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return data.role;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Check if user has permission
|
|
260
|
+
*/
|
|
261
|
+
async hasPermission(organizationId, userId, permission) {
|
|
262
|
+
const role = await this.getUserRole(organizationId, userId);
|
|
263
|
+
if (!role)
|
|
264
|
+
return false;
|
|
265
|
+
const { ROLE_PERMISSIONS } = await import('./saas-types.js');
|
|
266
|
+
return ROLE_PERMISSIONS[role][permission];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Get organization usage summary
|
|
270
|
+
*/
|
|
271
|
+
async getUsageSummary(organizationId) {
|
|
272
|
+
const { data, error } = await this.supabase
|
|
273
|
+
.from('organization_usage_summary')
|
|
274
|
+
.select('*')
|
|
275
|
+
.eq('organization_id', organizationId)
|
|
276
|
+
.single();
|
|
277
|
+
if (error) {
|
|
278
|
+
throw new Error(`Failed to get usage summary: ${error.message}`);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
organizationId: data.organization_id,
|
|
282
|
+
name: data.name,
|
|
283
|
+
slug: data.slug,
|
|
284
|
+
subscriptionTier: data.subscription_tier,
|
|
285
|
+
memberCount: data.member_count || 0,
|
|
286
|
+
teamCount: data.team_count || 0,
|
|
287
|
+
secretCount: data.secret_count || 0,
|
|
288
|
+
environmentCount: data.environment_count || 0,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Check if organization is within tier limits
|
|
293
|
+
*/
|
|
294
|
+
async checkTierLimits(organizationId) {
|
|
295
|
+
const org = await this.getOrganizationById(organizationId);
|
|
296
|
+
if (!org) {
|
|
297
|
+
throw new Error('NOT_FOUND: Organization not found');
|
|
298
|
+
}
|
|
299
|
+
const usage = await this.getUsageSummary(organizationId);
|
|
300
|
+
const { TIER_LIMITS } = await import('./saas-types.js');
|
|
301
|
+
const limits = TIER_LIMITS[org.subscriptionTier];
|
|
302
|
+
const violations = [];
|
|
303
|
+
if (usage.memberCount > limits.teamMembers) {
|
|
304
|
+
violations.push(`Team member limit exceeded (${usage.memberCount}/${limits.teamMembers})`);
|
|
305
|
+
}
|
|
306
|
+
if (usage.secretCount > limits.secrets) {
|
|
307
|
+
violations.push(`Secret limit exceeded (${usage.secretCount}/${limits.secrets})`);
|
|
308
|
+
}
|
|
309
|
+
if (usage.environmentCount > limits.environments) {
|
|
310
|
+
violations.push(`Environment limit exceeded (${usage.environmentCount}/${limits.environments})`);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
withinLimits: violations.length === 0,
|
|
314
|
+
violations,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Check member limit before adding
|
|
319
|
+
*/
|
|
320
|
+
async checkMemberLimit(organizationId) {
|
|
321
|
+
const usage = await this.getUsageSummary(organizationId);
|
|
322
|
+
const org = await this.getOrganizationById(organizationId);
|
|
323
|
+
if (!org) {
|
|
324
|
+
throw new Error('NOT_FOUND: Organization not found');
|
|
325
|
+
}
|
|
326
|
+
const { TIER_LIMITS } = await import('./saas-types.js');
|
|
327
|
+
const limits = TIER_LIMITS[org.subscriptionTier];
|
|
328
|
+
if (usage.memberCount >= limits.teamMembers) {
|
|
329
|
+
throw new Error('TIER_LIMIT_EXCEEDED: Team member limit reached. Please upgrade your plan.');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Map database org to Organization type
|
|
334
|
+
*/
|
|
335
|
+
mapDbOrgToOrg(dbOrg) {
|
|
336
|
+
return {
|
|
337
|
+
id: dbOrg.id,
|
|
338
|
+
name: dbOrg.name,
|
|
339
|
+
slug: dbOrg.slug,
|
|
340
|
+
createdAt: new Date(dbOrg.created_at),
|
|
341
|
+
updatedAt: new Date(dbOrg.updated_at),
|
|
342
|
+
stripeCustomerId: dbOrg.stripe_customer_id,
|
|
343
|
+
subscriptionTier: dbOrg.subscription_tier,
|
|
344
|
+
subscriptionStatus: dbOrg.subscription_status,
|
|
345
|
+
subscriptionExpiresAt: dbOrg.subscription_expires_at
|
|
346
|
+
? new Date(dbOrg.subscription_expires_at)
|
|
347
|
+
: null,
|
|
348
|
+
settings: dbOrg.settings || {},
|
|
349
|
+
deletedAt: dbOrg.deleted_at ? new Date(dbOrg.deleted_at) : null,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Map database member to OrganizationMember type
|
|
354
|
+
*/
|
|
355
|
+
mapDbMemberToMember(dbMember) {
|
|
356
|
+
return {
|
|
357
|
+
id: dbMember.id,
|
|
358
|
+
organizationId: dbMember.organization_id,
|
|
359
|
+
userId: dbMember.user_id,
|
|
360
|
+
role: dbMember.role,
|
|
361
|
+
invitedBy: dbMember.invited_by,
|
|
362
|
+
invitedAt: new Date(dbMember.invited_at),
|
|
363
|
+
acceptedAt: dbMember.accepted_at ? new Date(dbMember.accepted_at) : null,
|
|
364
|
+
createdAt: new Date(dbMember.created_at),
|
|
365
|
+
updatedAt: new Date(dbMember.updated_at),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Map database member detailed to OrganizationMemberDetailed type
|
|
370
|
+
*/
|
|
371
|
+
mapDbMemberDetailedToMemberDetailed(dbMember) {
|
|
372
|
+
return {
|
|
373
|
+
id: dbMember.id,
|
|
374
|
+
organizationId: dbMember.organization_id,
|
|
375
|
+
userId: dbMember.user_id,
|
|
376
|
+
role: dbMember.role,
|
|
377
|
+
invitedBy: dbMember.invited_by,
|
|
378
|
+
invitedAt: new Date(dbMember.invited_at),
|
|
379
|
+
acceptedAt: dbMember.accepted_at ? new Date(dbMember.accepted_at) : null,
|
|
380
|
+
createdAt: new Date(dbMember.created_at),
|
|
381
|
+
updatedAt: new Date(dbMember.updated_at),
|
|
382
|
+
email: dbMember.email,
|
|
383
|
+
firstName: dbMember.first_name,
|
|
384
|
+
lastName: dbMember.last_name,
|
|
385
|
+
avatarUrl: dbMember.avatar_url,
|
|
386
|
+
lastLoginAt: dbMember.last_login_at ? new Date(dbMember.last_login_at) : null,
|
|
387
|
+
organizationName: dbMember.organization_name,
|
|
388
|
+
organizationSlug: dbMember.organization_slug,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Team Service
|
|
394
|
+
*/
|
|
395
|
+
export class TeamService {
|
|
396
|
+
supabase = getSupabaseClient();
|
|
397
|
+
/**
|
|
398
|
+
* Create a new team
|
|
399
|
+
*/
|
|
400
|
+
async createTeam(input, createdBy) {
|
|
401
|
+
const slug = input.slug || slugify(input.name);
|
|
402
|
+
// Check if slug already exists in this org
|
|
403
|
+
const { data: existing } = await this.supabase
|
|
404
|
+
.from('teams')
|
|
405
|
+
.select('id')
|
|
406
|
+
.eq('organization_id', input.organizationId)
|
|
407
|
+
.eq('slug', slug)
|
|
408
|
+
.single();
|
|
409
|
+
if (existing) {
|
|
410
|
+
throw new Error('ALREADY_EXISTS: Team slug already taken in this organization');
|
|
411
|
+
}
|
|
412
|
+
const { data: team, error } = await this.supabase
|
|
413
|
+
.from('teams')
|
|
414
|
+
.insert({
|
|
415
|
+
organization_id: input.organizationId,
|
|
416
|
+
name: input.name,
|
|
417
|
+
slug,
|
|
418
|
+
description: input.description || null,
|
|
419
|
+
})
|
|
420
|
+
.select()
|
|
421
|
+
.single();
|
|
422
|
+
if (error) {
|
|
423
|
+
throw new Error(`Failed to create team: ${error.message}`);
|
|
424
|
+
}
|
|
425
|
+
await auditLogger.log({
|
|
426
|
+
organizationId: input.organizationId,
|
|
427
|
+
teamId: team.id,
|
|
428
|
+
userId: createdBy,
|
|
429
|
+
action: 'team.create',
|
|
430
|
+
resourceType: 'team',
|
|
431
|
+
resourceId: team.id,
|
|
432
|
+
newValue: { name: team.name, slug: team.slug },
|
|
433
|
+
});
|
|
434
|
+
return this.mapDbTeamToTeam(team);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Get team by ID
|
|
438
|
+
*/
|
|
439
|
+
async getTeamById(id) {
|
|
440
|
+
const { data: team, error } = await this.supabase
|
|
441
|
+
.from('teams')
|
|
442
|
+
.select('*')
|
|
443
|
+
.eq('id', id)
|
|
444
|
+
.is('deleted_at', null)
|
|
445
|
+
.single();
|
|
446
|
+
if (error || !team) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return this.mapDbTeamToTeam(team);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Get teams for organization
|
|
453
|
+
*/
|
|
454
|
+
async getOrganizationTeams(organizationId) {
|
|
455
|
+
const { data: teams, error } = await this.supabase
|
|
456
|
+
.from('teams')
|
|
457
|
+
.select('*')
|
|
458
|
+
.eq('organization_id', organizationId)
|
|
459
|
+
.is('deleted_at', null)
|
|
460
|
+
.order('created_at', { ascending: false });
|
|
461
|
+
if (error) {
|
|
462
|
+
throw new Error(`Failed to get teams: ${error.message}`);
|
|
463
|
+
}
|
|
464
|
+
return (teams || []).map(this.mapDbTeamToTeam);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Update team
|
|
468
|
+
*/
|
|
469
|
+
async updateTeam(id, updates, updatedBy) {
|
|
470
|
+
const { data: team, error } = await this.supabase
|
|
471
|
+
.from('teams')
|
|
472
|
+
.update(updates)
|
|
473
|
+
.eq('id', id)
|
|
474
|
+
.select()
|
|
475
|
+
.single();
|
|
476
|
+
if (error) {
|
|
477
|
+
throw new Error(`Failed to update team: ${error.message}`);
|
|
478
|
+
}
|
|
479
|
+
await auditLogger.log({
|
|
480
|
+
organizationId: team.organization_id,
|
|
481
|
+
teamId: id,
|
|
482
|
+
userId: updatedBy,
|
|
483
|
+
action: 'team.update',
|
|
484
|
+
resourceType: 'team',
|
|
485
|
+
resourceId: id,
|
|
486
|
+
newValue: updates,
|
|
487
|
+
});
|
|
488
|
+
return this.mapDbTeamToTeam(team);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Delete team (soft delete)
|
|
492
|
+
*/
|
|
493
|
+
async deleteTeam(id, deletedBy) {
|
|
494
|
+
const team = await this.getTeamById(id);
|
|
495
|
+
if (!team) {
|
|
496
|
+
throw new Error('NOT_FOUND: Team not found');
|
|
497
|
+
}
|
|
498
|
+
const { error } = await this.supabase
|
|
499
|
+
.from('teams')
|
|
500
|
+
.update({ deleted_at: new Date().toISOString() })
|
|
501
|
+
.eq('id', id);
|
|
502
|
+
if (error) {
|
|
503
|
+
throw new Error(`Failed to delete team: ${error.message}`);
|
|
504
|
+
}
|
|
505
|
+
await auditLogger.log({
|
|
506
|
+
organizationId: team.organizationId,
|
|
507
|
+
teamId: id,
|
|
508
|
+
userId: deletedBy,
|
|
509
|
+
action: 'team.delete',
|
|
510
|
+
resourceType: 'team',
|
|
511
|
+
resourceId: id,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Add member to team
|
|
516
|
+
*/
|
|
517
|
+
async addTeamMember(teamId, userId, role = 'member') {
|
|
518
|
+
const { data: member, error } = await this.supabase
|
|
519
|
+
.from('team_members')
|
|
520
|
+
.insert({
|
|
521
|
+
team_id: teamId,
|
|
522
|
+
user_id: userId,
|
|
523
|
+
role,
|
|
524
|
+
})
|
|
525
|
+
.select()
|
|
526
|
+
.single();
|
|
527
|
+
if (error) {
|
|
528
|
+
throw new Error(`Failed to add team member: ${error.message}`);
|
|
529
|
+
}
|
|
530
|
+
return this.mapDbTeamMemberToTeamMember(member);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Remove member from team
|
|
534
|
+
*/
|
|
535
|
+
async removeTeamMember(teamId, userId) {
|
|
536
|
+
const { error } = await this.supabase
|
|
537
|
+
.from('team_members')
|
|
538
|
+
.delete()
|
|
539
|
+
.eq('team_id', teamId)
|
|
540
|
+
.eq('user_id', userId);
|
|
541
|
+
if (error) {
|
|
542
|
+
throw new Error(`Failed to remove team member: ${error.message}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Get team members
|
|
547
|
+
*/
|
|
548
|
+
async getTeamMembers(teamId) {
|
|
549
|
+
const { data, error } = await this.supabase
|
|
550
|
+
.from('team_members_detailed')
|
|
551
|
+
.select('*')
|
|
552
|
+
.eq('team_id', teamId);
|
|
553
|
+
if (error) {
|
|
554
|
+
throw new Error(`Failed to get team members: ${error.message}`);
|
|
555
|
+
}
|
|
556
|
+
return data || [];
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Map database team to Team type
|
|
560
|
+
*/
|
|
561
|
+
mapDbTeamToTeam(dbTeam) {
|
|
562
|
+
return {
|
|
563
|
+
id: dbTeam.id,
|
|
564
|
+
organizationId: dbTeam.organization_id,
|
|
565
|
+
name: dbTeam.name,
|
|
566
|
+
slug: dbTeam.slug,
|
|
567
|
+
description: dbTeam.description,
|
|
568
|
+
encryptionKeyId: dbTeam.encryption_key_id,
|
|
569
|
+
createdAt: new Date(dbTeam.created_at),
|
|
570
|
+
updatedAt: new Date(dbTeam.updated_at),
|
|
571
|
+
deletedAt: dbTeam.deleted_at ? new Date(dbTeam.deleted_at) : null,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Map database team member to TeamMember type
|
|
576
|
+
*/
|
|
577
|
+
mapDbTeamMemberToTeamMember(dbMember) {
|
|
578
|
+
return {
|
|
579
|
+
id: dbMember.id,
|
|
580
|
+
teamId: dbMember.team_id,
|
|
581
|
+
userId: dbMember.user_id,
|
|
582
|
+
role: dbMember.role,
|
|
583
|
+
createdAt: new Date(dbMember.created_at),
|
|
584
|
+
updatedAt: new Date(dbMember.updated_at),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Singleton instances
|
|
590
|
+
*/
|
|
591
|
+
export const organizationService = new OrganizationService();
|
|
592
|
+
export const teamService = new TeamService();
|