Skip to content

Commit a0b0a64

Browse files
authored
AI-1063 Backend Functions for Group Membership Requests (#47)
1 parent 189ae87 commit a0b0a64

24 files changed

Lines changed: 1575 additions & 32 deletions

.github/copilot-instructions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@
1616
## Prisma and Database Guidelines
1717
- If you need to run `npx prisma generate`, run `npm run db:generate` from `apps/backend-services` - it's a special script that writes models into apps/temporal/src and apps/backend-services/src. Don't forget to run migrations as normal if necessary.
1818
- Tables should be designed with a `created_at` timestamp (default to now) and an `updated_at` timestamp (auto-updated on change) for auditing purposes.
19-
- Table names should be singular (e.g., `User`, not `Users`) to align with Prisma conventions.
19+
- Table names should be singular (e.g., `User`, not `Users`) to align with Prisma conventions.
20+
21+
## Requirements and User Stories
22+
- When finished implementing a user story, check it off in the related user stories file in `feature-docs/002-group-management/user_stories/README.md` and update the acceptance checklist.
23+
- If you find any gaps in the requirements or user stories, document them in the summary notes after implementation and ask for clarification before proceeding with any assumptions.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
name: requirements-refiner
3+
description: "Requirements Refiner: Iteratively questions the user to clarify vague requirements and outputs a single consolidated requirements document in feature-docs."
4+
---
5+
**Role**: You are an expert Technical Product Manager and Business Analyst. Your goal is to take initial, potentially vague business requirements and refine them into a clear, comprehensive specification by asking clarifying questions.
6+
7+
## Your Workflow
8+
9+
1. **Analyze**: Read the input text or file. Compare it against the standards defined in `.claude/skills/requirements-refiner/elicitation_standards.md`.
10+
2. **Iterative Elicitation**:
11+
- Identify gaps, ambiguities, or assumptions.
12+
- Ask a set of numbered clarifying questions.
13+
- Wait for the user's response.
14+
- Repeat this step until the requirements are clear and complete.
15+
3. **Consolidate**:
16+
- Once the requirements are fully understood, create a single consolidated document.
17+
- This document should include the initial requirements plus all details gathered during the Q&A process.
18+
- Ensure the output is structured and ready for a User Story writer to consume.
19+
4. **Save to feature-docs**:
20+
- Examine the `feature-docs/` directory to find the highest numbered folder (e.g., `001-graph-workflows`).
21+
- Determine the next increment number (e.g., if `001` exists, use `002`).
22+
- Generate a short feature name/slug (kebab-case, e.g., "benchmarking-system").
23+
- Create a new folder: `feature-docs/{NNN}-{feature-slug}/` (e.g., `feature-docs/002-benchmarking-system/`).
24+
- Save the consolidated requirements as `REQUIREMENTS.md` in that folder.
25+
26+
## Key Behaviors
27+
- **Iterative Approach**: Do not rush to the final output. Prioritize clarity over speed.
28+
- **Probe Deeply**: Ask about edge cases, error states, and user roles.
29+
- **Auto-increment Folders**: Always check existing feature-docs folders to determine the next number.
30+
- **Output Format**: The final output must be saved as `feature-docs/{NNN}-{feature-slug}/REQUIREMENTS.md`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Elicitation Standards & Tips
2+
3+
Use these categories to generate questions when analyzing requirements.
4+
5+
## 1. The "Who" (Personas)
6+
- Who exactly is the user? (Admin, Guest, Registered User?)
7+
- Are there different permissions or roles involved?
8+
- *Tip*: Never assume "User" is a single entity.
9+
10+
## 2. The "What" (Functional & Edge Cases)
11+
- **Happy Path**: What is the ideal flow?
12+
- **Unhappy Path**: What happens if...
13+
- The operation fails?
14+
- The data is missing?
15+
- The internet connection is lost?
16+
- The user inputs invalid data?
17+
18+
## 3. The "Where" & "When" (Context)
19+
- Is this mobile, desktop, or both?
20+
- Does this happen in real-time or as a background job?
21+
22+
## 4. The "How" (Data & Integrity)
23+
- Where does the data come from?
24+
- Do we need to validate formats (email, phone, dates)?
25+
- Are there security concerns (PII, specialized access)?
26+
27+
## 5. Non-Functional Requirements
28+
- **Performance**: How fast must it load?
29+
- **Scale**: How many headers/items?
30+
- **Localization**: Do we need multi-language support?
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
name: write-user-stories
3+
description: Convert a refined requirements document into one user story file per story in a user_stories subfolder using the strict template, plus a README.md with phase breakdown.
4+
argument-hint: refined-requirements-file
5+
context: fork
6+
agent: user-story-writer
7+
disable-model-invocation: true
8+
---
9+
Generate user stories from: $ARGUMENTS
10+
11+
Requirements:
12+
- Decompose into atomic, independent user stories.
13+
- Write each story using the strict template you have access to.
14+
- Create user_stories subfolder in the same directory as the requirements file.
15+
- Use filename format `US-{number}-{short-description}.md`.
16+
- Generate a README.md in the user_stories folder with all stories organized by phases with checkboxes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# [Story ID]: [Title of the Story]
2+
3+
**As a** [Persona],
4+
**I want to** [Action],
5+
**So that** [Benefit/Value].
6+
7+
## Acceptance Criteria
8+
- [ ] **Scenario 1**: [Title]
9+
- **Given** [Context]
10+
- **When** [Action]
11+
- **Then** [Outcome]
12+
13+
- [ ] **Scenario 2**: [Title]
14+
- **Given** ...
15+
- **When** ...
16+
- **Then** ...
17+
18+
## Priority
19+
- [ ] High (Must Have)
20+
- [ ] Medium (Should Have)
21+
- [ ] Low (Nice to Have)
22+
23+
## Technical Notes / Assumptions
24+
- [Add any technical constraints, data requirements, or assumptions here]
25+
- [e.g., "Requires update to User Profile API"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsOptional, IsString } from "class-validator";
3+
4+
/**
5+
* DTO for actions on a group membership request (approve, deny, cancel).
6+
* The actor's identity is derived from the JWT token, not this body.
7+
*/
8+
export class MembershipRequestActionDto {
9+
@ApiPropertyOptional({ description: "Optional reason for the action" })
10+
@IsOptional()
11+
@IsString()
12+
reason?: string;
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
import { IsString } from "class-validator";
3+
4+
/**
5+
* DTO for submitting a group membership request.
6+
* The requesting user's identity is derived from the JWT token, not this body.
7+
*/
8+
export class RequestMembershipDto {
9+
@ApiProperty({ description: "The ID of the group to request membership for" })
10+
@IsString()
11+
groupId: string;
12+
}

apps/backend-services/src/group/group.controller.spec.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { HttpException, HttpStatus } from "@nestjs/common";
12
import { Test, TestingModule } from "@nestjs/testing";
3+
import { RequestMembershipDto } from "./dto/request-membership.dto";
24
import { GroupController } from "./group.controller";
35
import { GroupService } from "./group.service";
46

@@ -14,6 +16,10 @@ describe("GroupController", () => {
1416
provide: GroupService,
1517
useValue: {
1618
assignUserToGroups: jest.fn(),
19+
requestMembership: jest.fn(),
20+
cancelMembershipRequest: jest.fn(),
21+
approveMembershipRequest: jest.fn(),
22+
denyMembershipRequest: jest.fn(),
1723
},
1824
},
1925
],
@@ -35,4 +41,181 @@ describe("GroupController", () => {
3541
it("should throw if groupIds is empty", async () => {
3642
await expect(controller.assignUserToGroups("user1", [])).rejects.toThrow();
3743
});
44+
45+
describe("requestMembership", () => {
46+
it("should call service with userId from JWT sub and groupId from body", async () => {
47+
const sub = "jwt-user-id";
48+
const body: RequestMembershipDto = { groupId: "group1" };
49+
jest.spyOn(service, "requestMembership").mockResolvedValueOnce();
50+
const req = { user: { sub } } as any;
51+
const result = await controller.requestMembership(req, body);
52+
expect(service.requestMembership).toHaveBeenCalledWith(sub, body.groupId);
53+
expect(result).toEqual({ success: true });
54+
});
55+
56+
it("should throw 401 if no user in request", async () => {
57+
const req = { user: undefined } as any;
58+
await expect(
59+
controller.requestMembership(req, { groupId: "group1" }),
60+
).rejects.toThrow(
61+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
62+
);
63+
});
64+
65+
it("should throw 401 if user has no sub claim", async () => {
66+
const req = { user: {} } as any;
67+
await expect(
68+
controller.requestMembership(req, { groupId: "group1" }),
69+
).rejects.toThrow(
70+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
71+
);
72+
});
73+
});
74+
75+
describe("cancelMembershipRequest", () => {
76+
it("should call service with userId from JWT, requestId from param, and reason from body", async () => {
77+
const sub = "jwt-user-id";
78+
const requestId = "req1";
79+
jest.spyOn(service, "cancelMembershipRequest").mockResolvedValueOnce();
80+
const req = { user: { sub } } as any;
81+
const result = await controller.cancelMembershipRequest(req, requestId, {
82+
reason: "No longer needed",
83+
});
84+
expect(service.cancelMembershipRequest).toHaveBeenCalledWith(
85+
sub,
86+
requestId,
87+
"No longer needed",
88+
);
89+
expect(result).toEqual({ success: true });
90+
});
91+
92+
it("should call service without reason when body has no reason", async () => {
93+
const sub = "jwt-user-id";
94+
const requestId = "req1";
95+
jest.spyOn(service, "cancelMembershipRequest").mockResolvedValueOnce();
96+
const req = { user: { sub } } as any;
97+
await controller.cancelMembershipRequest(req, requestId, {});
98+
expect(service.cancelMembershipRequest).toHaveBeenCalledWith(
99+
sub,
100+
requestId,
101+
undefined,
102+
);
103+
});
104+
105+
it("should throw 401 if no user in request", async () => {
106+
const req = { user: undefined } as any;
107+
await expect(
108+
controller.cancelMembershipRequest(req, "req1", {}),
109+
).rejects.toThrow(
110+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
111+
);
112+
});
113+
114+
it("should throw 401 if user has no sub claim", async () => {
115+
const req = { user: {} } as any;
116+
await expect(
117+
controller.cancelMembershipRequest(req, "req1", {}),
118+
).rejects.toThrow(
119+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
120+
);
121+
});
122+
});
123+
124+
describe("approveMembershipRequest", () => {
125+
it("should call service with adminId from JWT, requestId from param, and reason from body", async () => {
126+
const sub = "jwt-admin-id";
127+
const requestId = "req1";
128+
jest.spyOn(service, "approveMembershipRequest").mockResolvedValueOnce();
129+
const req = { user: { sub } } as any;
130+
const result = await controller.approveMembershipRequest(req, requestId, {
131+
reason: "Approved",
132+
});
133+
expect(service.approveMembershipRequest).toHaveBeenCalledWith(
134+
sub,
135+
requestId,
136+
"Approved",
137+
);
138+
expect(result).toEqual({ success: true });
139+
});
140+
141+
it("should call service without reason when body has no reason", async () => {
142+
const sub = "jwt-admin-id";
143+
const requestId = "req1";
144+
jest.spyOn(service, "approveMembershipRequest").mockResolvedValueOnce();
145+
const req = { user: { sub } } as any;
146+
await controller.approveMembershipRequest(req, requestId, {});
147+
expect(service.approveMembershipRequest).toHaveBeenCalledWith(
148+
sub,
149+
requestId,
150+
undefined,
151+
);
152+
});
153+
154+
it("should throw 401 if no user in request", async () => {
155+
const req = { user: undefined } as any;
156+
await expect(
157+
controller.approveMembershipRequest(req, "req1", {}),
158+
).rejects.toThrow(
159+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
160+
);
161+
});
162+
163+
it("should throw 401 if user has no sub claim", async () => {
164+
const req = { user: {} } as any;
165+
await expect(
166+
controller.approveMembershipRequest(req, "req1", {}),
167+
).rejects.toThrow(
168+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
169+
);
170+
});
171+
});
172+
173+
describe("denyMembershipRequest", () => {
174+
it("should call service with adminId from JWT, requestId from param, and reason from body", async () => {
175+
const sub = "jwt-admin-id";
176+
const requestId = "req1";
177+
jest.spyOn(service, "denyMembershipRequest").mockResolvedValueOnce();
178+
const req = { user: { sub } } as any;
179+
const result = await controller.denyMembershipRequest(req, requestId, {
180+
reason: "Not eligible",
181+
});
182+
expect(service.denyMembershipRequest).toHaveBeenCalledWith(
183+
sub,
184+
requestId,
185+
"Not eligible",
186+
);
187+
expect(result).toEqual({ success: true });
188+
});
189+
190+
it("should call service without reason when body has no reason", async () => {
191+
const sub = "jwt-admin-id";
192+
const requestId = "req1";
193+
jest.spyOn(service, "denyMembershipRequest").mockResolvedValueOnce();
194+
const req = { user: { sub } } as any;
195+
await controller.denyMembershipRequest(req, requestId, {});
196+
expect(service.denyMembershipRequest).toHaveBeenCalledWith(
197+
sub,
198+
requestId,
199+
undefined,
200+
);
201+
});
202+
203+
it("should throw 401 if no user in request", async () => {
204+
const req = { user: undefined } as any;
205+
await expect(
206+
controller.denyMembershipRequest(req, "req1", {}),
207+
).rejects.toThrow(
208+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
209+
);
210+
});
211+
212+
it("should throw 401 if user has no sub claim", async () => {
213+
const req = { user: {} } as any;
214+
await expect(
215+
controller.denyMembershipRequest(req, "req1", {}),
216+
).rejects.toThrow(
217+
new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED),
218+
);
219+
});
220+
});
38221
});

0 commit comments

Comments
 (0)