@mars-stack/cli 0.2.0 → 0.2.2
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/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
# Skill: Setup Teams (Meta-Skill)
|
|
2
|
+
|
|
3
|
+
Orchestrate multi-tenancy with teams/organizations: data models, invitation flow, role hierarchy, org switching, team settings UI, and documentation updates.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to:
|
|
8
|
+
- Add teams or organizations
|
|
9
|
+
- Set up workspaces or multi-tenancy
|
|
10
|
+
- Add team invitations or member management
|
|
11
|
+
- Build org switching functionality
|
|
12
|
+
|
|
13
|
+
This meta-skill chains sub-skills in the correct order. For just the multi-tenancy data model, use `configure-multi-tenancy` instead.
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Read `src/config/app.config.ts` to check if multi-tenancy features exist
|
|
18
|
+
- Read `prisma/schema/auth.prisma` to understand the current User model
|
|
19
|
+
- Read `src/lib/mars.ts` to check available auth wrappers
|
|
20
|
+
|
|
21
|
+
## Execution Order
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌─────────────────────────────────────────────────────┐
|
|
25
|
+
│ Phase 0: Plan │
|
|
26
|
+
│ create-execution-plan │
|
|
27
|
+
├─────────────────────────────────────────────────────┤
|
|
28
|
+
│ Phase 1: Multi-Tenancy Foundation │
|
|
29
|
+
│ configure-multi-tenancy (tenant model, scoping) │
|
|
30
|
+
├─────────────────────────────────────────────────────┤
|
|
31
|
+
│ Phase 2: Data Layer │
|
|
32
|
+
│ add-prisma-model (Team, Membership, Invitation) │
|
|
33
|
+
├─────────────────────────────────────────────────────┤
|
|
34
|
+
│ Phase 3: Feature Module │
|
|
35
|
+
│ add-feature (teams service, invitation logic) │
|
|
36
|
+
├─────────────────────────────────────────────────────┤
|
|
37
|
+
│ Phase 4: API Routes │
|
|
38
|
+
│ add-crud-routes (teams, members, invitations) │
|
|
39
|
+
├─────────────────────────────────────────────────────┤
|
|
40
|
+
│ Phase 5: UI │
|
|
41
|
+
│ add-page (team settings, invite, org switcher) │
|
|
42
|
+
├─────────────────────────────────────────────────────┤
|
|
43
|
+
│ Phase 6: Auth Integration │
|
|
44
|
+
│ Update session, middleware, and context │
|
|
45
|
+
├─────────────────────────────────────────────────────┤
|
|
46
|
+
│ Phase 7: Testing & Docs │
|
|
47
|
+
│ test-api-route → update-architecture-docs │
|
|
48
|
+
└─────────────────────────────────────────────────────┘
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Phase 0: Create Execution Plan
|
|
52
|
+
|
|
53
|
+
**Skill:** `create-execution-plan`
|
|
54
|
+
|
|
55
|
+
Create `docs/exec-plans/active/setup-teams.md` with all tasks listed below.
|
|
56
|
+
|
|
57
|
+
## Phase 1: Multi-Tenancy Foundation
|
|
58
|
+
|
|
59
|
+
**Skill:** `configure-multi-tenancy`
|
|
60
|
+
|
|
61
|
+
1. Enable feature flag:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// src/config/app.config.ts
|
|
65
|
+
features: {
|
|
66
|
+
teams: true,
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
2. Decide on tenancy model:
|
|
71
|
+
|
|
72
|
+
| Model | Description | Use when |
|
|
73
|
+
|-------|-------------|----------|
|
|
74
|
+
| **Team-based** | Users belong to one or more teams | B2B SaaS, collaboration tools |
|
|
75
|
+
| **Org-based** | Hierarchical: Org → Team → User | Enterprise, large organizations |
|
|
76
|
+
| **Workspace-based** | Flat: Users switch between workspaces | Project management, dev tools |
|
|
77
|
+
|
|
78
|
+
This skill assumes **Team-based** tenancy. Adjust models for org/workspace variants.
|
|
79
|
+
|
|
80
|
+
## Phase 2: Data Layer
|
|
81
|
+
|
|
82
|
+
**Skill:** `add-prisma-model`
|
|
83
|
+
|
|
84
|
+
### Team model
|
|
85
|
+
|
|
86
|
+
```prisma
|
|
87
|
+
// prisma/schema/teams.prisma
|
|
88
|
+
model Team {
|
|
89
|
+
id String @id @default(cuid())
|
|
90
|
+
name String
|
|
91
|
+
slug String @unique
|
|
92
|
+
avatarUrl String?
|
|
93
|
+
createdAt DateTime @default(now())
|
|
94
|
+
updatedAt DateTime @updatedAt
|
|
95
|
+
|
|
96
|
+
memberships Membership[]
|
|
97
|
+
invitations Invitation[]
|
|
98
|
+
|
|
99
|
+
@@index([slug])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
model Membership {
|
|
103
|
+
id String @id @default(cuid())
|
|
104
|
+
userId String
|
|
105
|
+
teamId String
|
|
106
|
+
role MembershipRole @default(MEMBER)
|
|
107
|
+
createdAt DateTime @default(now())
|
|
108
|
+
updatedAt DateTime @updatedAt
|
|
109
|
+
|
|
110
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
111
|
+
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
112
|
+
|
|
113
|
+
@@unique([userId, teamId])
|
|
114
|
+
@@index([userId])
|
|
115
|
+
@@index([teamId])
|
|
116
|
+
@@index([role])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
model Invitation {
|
|
120
|
+
id String @id @default(cuid())
|
|
121
|
+
email String
|
|
122
|
+
teamId String
|
|
123
|
+
role MembershipRole @default(MEMBER)
|
|
124
|
+
token String @unique @default(cuid())
|
|
125
|
+
status InvitationStatus @default(PENDING)
|
|
126
|
+
invitedBy String
|
|
127
|
+
expiresAt DateTime
|
|
128
|
+
createdAt DateTime @default(now())
|
|
129
|
+
|
|
130
|
+
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
131
|
+
inviter User @relation("InvitationsSent", fields: [invitedBy], references: [id])
|
|
132
|
+
|
|
133
|
+
@@index([email])
|
|
134
|
+
@@index([teamId])
|
|
135
|
+
@@index([token])
|
|
136
|
+
@@index([status])
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
enum MembershipRole {
|
|
140
|
+
OWNER
|
|
141
|
+
ADMIN
|
|
142
|
+
MEMBER
|
|
143
|
+
VIEWER
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
enum InvitationStatus {
|
|
147
|
+
PENDING
|
|
148
|
+
ACCEPTED
|
|
149
|
+
EXPIRED
|
|
150
|
+
REVOKED
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Update User model
|
|
155
|
+
|
|
156
|
+
```prisma
|
|
157
|
+
// In prisma/schema/auth.prisma
|
|
158
|
+
model User {
|
|
159
|
+
// ... existing fields
|
|
160
|
+
memberships Membership[]
|
|
161
|
+
invitationsSent Invitation[] @relation("InvitationsSent")
|
|
162
|
+
activeTeamId String?
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Run `yarn db:push`.
|
|
167
|
+
|
|
168
|
+
## Phase 3: Feature Module
|
|
169
|
+
|
|
170
|
+
**Skill:** `add-feature`
|
|
171
|
+
|
|
172
|
+
Create `src/features/teams/`:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
src/features/teams/
|
|
176
|
+
├── components/
|
|
177
|
+
│ ├── TeamSwitcher.tsx # Org/team switcher dropdown
|
|
178
|
+
│ ├── MemberList.tsx # Team member list with roles
|
|
179
|
+
│ ├── InviteForm.tsx # Invite new member form
|
|
180
|
+
│ ├── InvitationList.tsx # Pending invitations
|
|
181
|
+
│ ├── TeamSettingsForm.tsx # Edit team name, avatar
|
|
182
|
+
│ └── index.ts
|
|
183
|
+
├── server/
|
|
184
|
+
│ ├── index.ts # Team queries and mutations
|
|
185
|
+
│ ├── invitations.ts # Invitation logic
|
|
186
|
+
│ └── membership.ts # Membership management
|
|
187
|
+
├── hooks/
|
|
188
|
+
│ ├── useActiveTeam.ts # Current team context hook
|
|
189
|
+
│ └── index.ts
|
|
190
|
+
├── validation/
|
|
191
|
+
│ └── schemas.ts # Zod schemas
|
|
192
|
+
└── types.ts # Team-related types
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Server module: Teams
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// src/features/teams/server/index.ts
|
|
199
|
+
import 'server-only';
|
|
200
|
+
|
|
201
|
+
import { prisma } from '@/lib/prisma';
|
|
202
|
+
|
|
203
|
+
export async function getUserTeams(userId: string) {
|
|
204
|
+
return prisma.membership.findMany({
|
|
205
|
+
where: { userId },
|
|
206
|
+
include: { team: true },
|
|
207
|
+
orderBy: { createdAt: 'asc' },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function getTeamById(teamId: string, userId: string) {
|
|
212
|
+
return prisma.team.findFirst({
|
|
213
|
+
where: {
|
|
214
|
+
id: teamId,
|
|
215
|
+
memberships: { some: { userId } },
|
|
216
|
+
},
|
|
217
|
+
include: {
|
|
218
|
+
memberships: {
|
|
219
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
220
|
+
orderBy: { createdAt: 'asc' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function createTeam(userId: string, name: string) {
|
|
227
|
+
const slug = generateSlug(name);
|
|
228
|
+
|
|
229
|
+
return prisma.$transaction(async (tx) => {
|
|
230
|
+
const team = await tx.team.create({
|
|
231
|
+
data: { name, slug },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await tx.membership.create({
|
|
235
|
+
data: { userId, teamId: team.id, role: 'OWNER' },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await tx.user.update({
|
|
239
|
+
where: { id: userId },
|
|
240
|
+
data: { activeTeamId: team.id },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return team;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function generateSlug(name: string): string {
|
|
248
|
+
return name
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
251
|
+
.replace(/^-|-$/g, '')
|
|
252
|
+
+ '-' + Math.random().toString(36).slice(2, 6);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Server module: Invitations
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// src/features/teams/server/invitations.ts
|
|
260
|
+
import 'server-only';
|
|
261
|
+
|
|
262
|
+
import { prisma } from '@/lib/prisma';
|
|
263
|
+
import { apiLogger } from '@/lib/mars';
|
|
264
|
+
import type { MembershipRole } from '@db';
|
|
265
|
+
|
|
266
|
+
export async function createInvitation(
|
|
267
|
+
teamId: string,
|
|
268
|
+
email: string,
|
|
269
|
+
role: MembershipRole,
|
|
270
|
+
invitedBy: string,
|
|
271
|
+
) {
|
|
272
|
+
const existingMember = await prisma.membership.findFirst({
|
|
273
|
+
where: {
|
|
274
|
+
teamId,
|
|
275
|
+
user: { email },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (existingMember) {
|
|
280
|
+
throw new Error('User is already a member of this team');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const existingInvite = await prisma.invitation.findFirst({
|
|
284
|
+
where: { teamId, email, status: 'PENDING' },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (existingInvite) {
|
|
288
|
+
throw new Error('An invitation is already pending for this email');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const expiresAt = new Date();
|
|
292
|
+
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
293
|
+
|
|
294
|
+
const invitation = await prisma.invitation.create({
|
|
295
|
+
data: { teamId, email, role, invitedBy, expiresAt },
|
|
296
|
+
include: { team: true },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
apiLogger.info({ teamId, email, role }, 'Team invitation created');
|
|
300
|
+
|
|
301
|
+
return invitation;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function acceptInvitation(token: string, userId: string) {
|
|
305
|
+
const invitation = await prisma.invitation.findUnique({
|
|
306
|
+
where: { token },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (!invitation) throw new Error('Invitation not found');
|
|
310
|
+
if (invitation.status !== 'PENDING') throw new Error('Invitation is no longer valid');
|
|
311
|
+
if (invitation.expiresAt < new Date()) {
|
|
312
|
+
await prisma.invitation.update({
|
|
313
|
+
where: { id: invitation.id },
|
|
314
|
+
data: { status: 'EXPIRED' },
|
|
315
|
+
});
|
|
316
|
+
throw new Error('Invitation has expired');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return prisma.$transaction(async (tx) => {
|
|
320
|
+
await tx.invitation.update({
|
|
321
|
+
where: { id: invitation.id },
|
|
322
|
+
data: { status: 'ACCEPTED' },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const membership = await tx.membership.create({
|
|
326
|
+
data: {
|
|
327
|
+
userId,
|
|
328
|
+
teamId: invitation.teamId,
|
|
329
|
+
role: invitation.role,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return membership;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function revokeInvitation(invitationId: string, teamId: string) {
|
|
338
|
+
return prisma.invitation.update({
|
|
339
|
+
where: { id: invitationId, teamId },
|
|
340
|
+
data: { status: 'REVOKED' },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Role hierarchy
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
// src/features/teams/server/membership.ts
|
|
349
|
+
import 'server-only';
|
|
350
|
+
|
|
351
|
+
import { prisma } from '@/lib/prisma';
|
|
352
|
+
import type { MembershipRole } from '@db';
|
|
353
|
+
|
|
354
|
+
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
|
|
355
|
+
OWNER: 4,
|
|
356
|
+
ADMIN: 3,
|
|
357
|
+
MEMBER: 2,
|
|
358
|
+
VIEWER: 1,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export function canManageRole(actorRole: MembershipRole, targetRole: MembershipRole): boolean {
|
|
362
|
+
return ROLE_HIERARCHY[actorRole] > ROLE_HIERARCHY[targetRole];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function updateMemberRole(
|
|
366
|
+
teamId: string,
|
|
367
|
+
targetUserId: string,
|
|
368
|
+
newRole: MembershipRole,
|
|
369
|
+
actorUserId: string,
|
|
370
|
+
) {
|
|
371
|
+
const actorMembership = await prisma.membership.findUnique({
|
|
372
|
+
where: { userId_teamId: { userId: actorUserId, teamId } },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (!actorMembership || !canManageRole(actorMembership.role, newRole)) {
|
|
376
|
+
throw new Error('Insufficient permissions to assign this role');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return prisma.membership.update({
|
|
380
|
+
where: { userId_teamId: { userId: targetUserId, teamId } },
|
|
381
|
+
data: { role: newRole },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function removeMember(
|
|
386
|
+
teamId: string,
|
|
387
|
+
targetUserId: string,
|
|
388
|
+
actorUserId: string,
|
|
389
|
+
) {
|
|
390
|
+
if (targetUserId === actorUserId) {
|
|
391
|
+
throw new Error('Cannot remove yourself. Transfer ownership first.');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const actorMembership = await prisma.membership.findUnique({
|
|
395
|
+
where: { userId_teamId: { userId: actorUserId, teamId } },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const targetMembership = await prisma.membership.findUnique({
|
|
399
|
+
where: { userId_teamId: { userId: targetUserId, teamId } },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!actorMembership || !targetMembership) {
|
|
403
|
+
throw new Error('Membership not found');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!canManageRole(actorMembership.role, targetMembership.role)) {
|
|
407
|
+
throw new Error('Insufficient permissions');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return prisma.membership.delete({
|
|
411
|
+
where: { userId_teamId: { userId: targetUserId, teamId } },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Phase 4: API Routes
|
|
417
|
+
|
|
418
|
+
**Skill:** `add-crud-routes`
|
|
419
|
+
|
|
420
|
+
### Team routes
|
|
421
|
+
|
|
422
|
+
| Route | Method | Auth | Purpose |
|
|
423
|
+
|-------|--------|------|---------|
|
|
424
|
+
| `/api/protected/teams` | GET | `withAuthNoParams` | List user's teams |
|
|
425
|
+
| `/api/protected/teams` | POST | `withAuthNoParams` | Create new team |
|
|
426
|
+
| `/api/protected/teams/[teamId]` | GET | `withAuth` | Get team details |
|
|
427
|
+
| `/api/protected/teams/[teamId]` | PUT | `withAuth` | Update team settings |
|
|
428
|
+
| `/api/protected/teams/[teamId]` | DELETE | `withAuth` | Delete team (owner only) |
|
|
429
|
+
|
|
430
|
+
### Member routes
|
|
431
|
+
|
|
432
|
+
| Route | Method | Auth | Purpose |
|
|
433
|
+
|-------|--------|------|---------|
|
|
434
|
+
| `/api/protected/teams/[teamId]/members` | GET | `withAuth` | List team members |
|
|
435
|
+
| `/api/protected/teams/[teamId]/members/[userId]` | PUT | `withAuth` | Update member role |
|
|
436
|
+
| `/api/protected/teams/[teamId]/members/[userId]` | DELETE | `withAuth` | Remove member |
|
|
437
|
+
|
|
438
|
+
### Invitation routes
|
|
439
|
+
|
|
440
|
+
| Route | Method | Auth | Purpose |
|
|
441
|
+
|-------|--------|------|---------|
|
|
442
|
+
| `/api/protected/teams/[teamId]/invitations` | GET | `withAuth` | List pending invitations |
|
|
443
|
+
| `/api/protected/teams/[teamId]/invitations` | POST | `withAuth` | Send invitation |
|
|
444
|
+
| `/api/protected/teams/[teamId]/invitations/[id]` | DELETE | `withAuth` | Revoke invitation |
|
|
445
|
+
| `/api/protected/invitations/accept` | POST | `withAuthNoParams` | Accept invitation by token |
|
|
446
|
+
|
|
447
|
+
### Active team route
|
|
448
|
+
|
|
449
|
+
| Route | Method | Auth | Purpose |
|
|
450
|
+
|-------|--------|------|---------|
|
|
451
|
+
| `/api/protected/user/active-team` | PUT | `withAuthNoParams` | Switch active team |
|
|
452
|
+
|
|
453
|
+
**All team-scoped routes must verify membership:**
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
export const GET = withAuth(async (request, { params }) => {
|
|
457
|
+
const { teamId } = await params;
|
|
458
|
+
const team = await getTeamById(teamId, request.session.userId);
|
|
459
|
+
if (!team) {
|
|
460
|
+
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
|
|
461
|
+
}
|
|
462
|
+
// ...
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Role-gated operations (admin+ for invitations, owner for delete):**
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
const membership = team.memberships.find(m => m.userId === request.session.userId);
|
|
470
|
+
if (!membership || ROLE_HIERARCHY[membership.role] < ROLE_HIERARCHY.ADMIN) {
|
|
471
|
+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Phase 5: UI
|
|
476
|
+
|
|
477
|
+
**Skill:** `add-page`
|
|
478
|
+
|
|
479
|
+
### Team settings page
|
|
480
|
+
|
|
481
|
+
Create `src/app/(protected)/settings/team/page.tsx`:
|
|
482
|
+
- Team name and slug editor
|
|
483
|
+
- Avatar upload (if storage configured)
|
|
484
|
+
- Member list with role badges
|
|
485
|
+
- Invite member form
|
|
486
|
+
- Pending invitations list
|
|
487
|
+
- Danger zone: delete team (owner only)
|
|
488
|
+
|
|
489
|
+
### Team switcher component
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
// src/features/teams/components/TeamSwitcher.tsx
|
|
493
|
+
'use client';
|
|
494
|
+
|
|
495
|
+
import { useActiveTeam } from '@/features/teams/hooks/useActiveTeam';
|
|
496
|
+
import { Select } from '@mars-stack/ui';
|
|
497
|
+
|
|
498
|
+
export function TeamSwitcher() {
|
|
499
|
+
const { teams, activeTeam, switchTeam, loading } = useActiveTeam();
|
|
500
|
+
|
|
501
|
+
if (loading || teams.length <= 1) return null;
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<Select
|
|
505
|
+
value={activeTeam?.id}
|
|
506
|
+
onChange={(teamId) => switchTeam(teamId)}
|
|
507
|
+
options={teams.map(t => ({ value: t.team.id, label: t.team.name }))}
|
|
508
|
+
/>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Active team hook
|
|
514
|
+
|
|
515
|
+
```tsx
|
|
516
|
+
// src/features/teams/hooks/useActiveTeam.ts
|
|
517
|
+
'use client';
|
|
518
|
+
|
|
519
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
520
|
+
|
|
521
|
+
interface TeamMembership {
|
|
522
|
+
id: string;
|
|
523
|
+
role: string;
|
|
524
|
+
team: { id: string; name: string; slug: string };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function useActiveTeam() {
|
|
528
|
+
const [teams, setTeams] = useState<TeamMembership[]>([]);
|
|
529
|
+
const [activeTeam, setActiveTeam] = useState<TeamMembership | null>(null);
|
|
530
|
+
const [loading, setLoading] = useState(true);
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
fetch('/api/protected/teams')
|
|
534
|
+
.then(res => res.json())
|
|
535
|
+
.then((data: TeamMembership[]) => {
|
|
536
|
+
setTeams(data);
|
|
537
|
+
const active = data.find(t => t.team.id === localStorage.getItem('activeTeamId'));
|
|
538
|
+
setActiveTeam(active ?? data[0] ?? null);
|
|
539
|
+
})
|
|
540
|
+
.finally(() => setLoading(false));
|
|
541
|
+
}, []);
|
|
542
|
+
|
|
543
|
+
const switchTeam = useCallback(async (teamId: string) => {
|
|
544
|
+
await fetch('/api/protected/user/active-team', {
|
|
545
|
+
method: 'PUT',
|
|
546
|
+
headers: { 'Content-Type': 'application/json' },
|
|
547
|
+
body: JSON.stringify({ teamId }),
|
|
548
|
+
});
|
|
549
|
+
localStorage.setItem('activeTeamId', teamId);
|
|
550
|
+
const team = teams.find(t => t.team.id === teamId);
|
|
551
|
+
if (team) setActiveTeam(team);
|
|
552
|
+
}, [teams]);
|
|
553
|
+
|
|
554
|
+
return { teams, activeTeam, switchTeam, loading };
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Invitation acceptance page
|
|
559
|
+
|
|
560
|
+
Create `src/app/(protected)/invitations/accept/page.tsx`:
|
|
561
|
+
- Reads `?token=` from search params
|
|
562
|
+
- Calls accept invitation API
|
|
563
|
+
- Redirects to the team on success
|
|
564
|
+
|
|
565
|
+
### Invitation email
|
|
566
|
+
|
|
567
|
+
If email is configured, send invitation emails using the `configure-email` skill pattern:
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
await sendEmail({
|
|
571
|
+
to: email,
|
|
572
|
+
subject: `You've been invited to join ${team.name}`,
|
|
573
|
+
html: invitationEmailTemplate({ team, inviterName, acceptUrl }),
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
## Phase 6: Auth Integration
|
|
578
|
+
|
|
579
|
+
### Extend session with team context
|
|
580
|
+
|
|
581
|
+
Add `activeTeamId` to the JWT session claims so team-scoped queries can use it:
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
// When creating/refreshing session, include activeTeamId
|
|
585
|
+
const sessionData = {
|
|
586
|
+
userId: user.id,
|
|
587
|
+
email: user.email,
|
|
588
|
+
role: user.role,
|
|
589
|
+
activeTeamId: user.activeTeamId,
|
|
590
|
+
};
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Team-scoped queries
|
|
594
|
+
|
|
595
|
+
For features that are team-scoped, queries should filter by `teamId`:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
export async function findProjectsByTeam(teamId: string) {
|
|
599
|
+
return prisma.project.findMany({
|
|
600
|
+
where: { teamId },
|
|
601
|
+
orderBy: { createdAt: 'desc' },
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### Middleware consideration
|
|
607
|
+
|
|
608
|
+
If certain routes should only be accessible to team members, add team verification in the route handler (not middleware, to keep middleware thin).
|
|
609
|
+
|
|
610
|
+
## Phase 7: Testing & Documentation
|
|
611
|
+
|
|
612
|
+
**Skill:** `test-api-route` + `update-architecture-docs`
|
|
613
|
+
|
|
614
|
+
### Tests
|
|
615
|
+
|
|
616
|
+
1. Unit test team CRUD routes
|
|
617
|
+
2. Unit test invitation flow (create, accept, revoke, expiry)
|
|
618
|
+
3. Unit test role hierarchy (canManageRole, permission checks)
|
|
619
|
+
4. Unit test membership management (add, remove, update role)
|
|
620
|
+
5. E2E test: create team → invite member → accept → verify access
|
|
621
|
+
|
|
622
|
+
### Documentation updates
|
|
623
|
+
|
|
624
|
+
1. Update `docs/QUALITY_SCORE.md`:
|
|
625
|
+
|
|
626
|
+
```markdown
|
|
627
|
+
| Teams | B | Team CRUD, invitations, role hierarchy, org switching |
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
2. Update `AGENTS.md`:
|
|
631
|
+
- Add teams feature to directory listing
|
|
632
|
+
- Document team-scoped query pattern
|
|
633
|
+
|
|
634
|
+
3. Mark execution plan as complete
|
|
635
|
+
|
|
636
|
+
## Data Scoping Decision
|
|
637
|
+
|
|
638
|
+
**When to scope data by team vs. by user:**
|
|
639
|
+
|
|
640
|
+
| Scope by Team | Scope by User |
|
|
641
|
+
|---------------|---------------|
|
|
642
|
+
| Projects, documents, shared resources | Personal settings, profile |
|
|
643
|
+
| Billing and subscriptions | Notification preferences |
|
|
644
|
+
| API keys and integrations | Security settings |
|
|
645
|
+
| Audit logs (team actions) | Auth sessions |
|
|
646
|
+
|
|
647
|
+
For team-scoped models, add `teamId` field and index:
|
|
648
|
+
|
|
649
|
+
```prisma
|
|
650
|
+
model Project {
|
|
651
|
+
// ...
|
|
652
|
+
teamId String
|
|
653
|
+
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
654
|
+
|
|
655
|
+
@@index([teamId])
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
## Checklist
|
|
660
|
+
|
|
661
|
+
- [ ] Execution plan created
|
|
662
|
+
- [ ] Multi-tenancy feature flag enabled
|
|
663
|
+
- [ ] Team, Membership, Invitation models in Prisma
|
|
664
|
+
- [ ] Membership role enum (OWNER, ADMIN, MEMBER, VIEWER)
|
|
665
|
+
- [ ] Invitation status enum (PENDING, ACCEPTED, EXPIRED, REVOKED)
|
|
666
|
+
- [ ] User model extended with `activeTeamId`
|
|
667
|
+
- [ ] Team CRUD server module
|
|
668
|
+
- [ ] Invitation server module (create, accept, revoke)
|
|
669
|
+
- [ ] Role hierarchy with permission checks
|
|
670
|
+
- [ ] Team CRUD API routes
|
|
671
|
+
- [ ] Member management API routes
|
|
672
|
+
- [ ] Invitation API routes
|
|
673
|
+
- [ ] Active team switching API route
|
|
674
|
+
- [ ] Team settings page with member management
|
|
675
|
+
- [ ] Team switcher component
|
|
676
|
+
- [ ] Invitation acceptance flow
|
|
677
|
+
- [ ] Session extended with activeTeamId
|
|
678
|
+
- [ ] Invitation emails (if email configured)
|
|
679
|
+
- [ ] Unit tests for all API routes
|
|
680
|
+
- [ ] E2E test for invitation flow
|
|
681
|
+
- [ ] QUALITY_SCORE.md updated
|
|
682
|
+
- [ ] Execution plan completed
|