diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000..87edb86 --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,216 @@ +{ + "main": { + "id": "8c9386b19c91147b", + "type": "split", + "children": [ + { + "id": "8864c640b792b187", + "type": "tabs", + "children": [ + { + "id": "1429c13e9a05c89c", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "product/iterations/README.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "README" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "679b90edf1780cc9", + "type": "split", + "children": [ + { + "id": "3942d2d324c197b4", + "type": "tabs", + "children": [ + { + "id": "34d31476315ecc4d", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "Files" + } + }, + { + "id": "12a7d42e402fcd44", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "Search" + } + }, + { + "id": "3989c6374832e5dd", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "Bookmarks" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "c3c8283d06725f20", + "type": "split", + "children": [ + { + "id": "0aea389e684d0637", + "type": "tabs", + "children": [ + { + "id": "48cb1f074dc3d555", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "product/iterations/README.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "Backlinks for README" + } + }, + { + "id": "0dc7eff9844a1a31", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "product/iterations/README.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "Outgoing links from README" + } + }, + { + "id": "cc9802a54c87c0fd", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "Tags" + } + }, + { + "id": "e726800e5550aaff", + "type": "leaf", + "state": { + "type": "all-properties", + "state": { + "sortOrder": "frequency", + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-archive", + "title": "All properties" + } + }, + { + "id": "15f16edd4dcfd933", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "product/iterations/README.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "Outline of README" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false, + "bases:Create new base": false + } + }, + "active": "1429c13e9a05c89c", + "lastOpenFiles": [ + "product/iterations/2025-12-06-webhooks/stories/story-056-slack-payload-formatting.md", + "product/iterations/2025-12-06-webhooks/stories/story-055-webhook-error-logging.md", + "product/iterations/2025-12-06-webhooks/stories/story-054-webhook-config-file.md", + "product/iterations/2025-12-06-webhooks/stories/story-053-summary-payload-schema.md", + "product/iterations/2025-12-06-webhooks/stories/story-052-webhook-dispatch-on-submission.md", + "product/iterations/2025-12-06-webhooks/stories/stories-index.md", + "product/iterations/2025-12-06-webhooks/stories/story-053-slack-channel-integration.md", + "product/iterations/2025-12-06-webhooks/stories/story-052-webhook-notification-service.md", + "product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md", + "product/iterations/2025-12-06-webhooks/discovery/interviews/interview-jeremy-2025-12-06.md", + "product/iterations/2025-12-06-webhooks/discovery/interviews/interview-template.md", + "product/iterations/2025-12-06-webhooks/discovery/README.md", + "product/iterations/2025-12-06-webhooks/design", + "product/iterations/2025-12-06-webhooks/story-maps", + "product/iterations/2025-12-06-webhooks/stories", + "product/iterations/2025-12-06-webhooks/discovery/synthesis", + "product/iterations/2025-12-06-webhooks/discovery/observations", + "product/iterations/2025-12-06-webhooks/discovery/interviews", + "product/iterations/2025-12-06-webhooks/discovery", + "product/iterations/2025-12-06-webhooks", + "product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-jeremy-2025-12-06.md", + "product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-template.md", + "product/iterations/2025-12-06-sandbox-js1/discovery/README.md", + "product/iterations/2025-12-06-sandbox-js1/design", + "product/iterations/2025-12-06-sandbox-js1/story-maps", + "2025-11-12-mvp.md", + "product/iterations/README.md" + ] +} \ No newline at end of file diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 53373c7..7b4f708 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -6,4 +6,7 @@ PORT=3001 NODE_ENV=development # Frontend CORS -FRONTEND_URL=http://localhost:3000 \ No newline at end of file +FRONTEND_URL=http://localhost:3000 + +# Webhook Configuration (optional) +WEBHOOK_URL=https://webhook.site/6165643b-3526-4086-8ceb-523bc0633375 \ No newline at end of file diff --git a/apps/backend/src/modules/survey/survey.module.ts b/apps/backend/src/modules/survey/survey.module.ts index 393db2b..9885cbc 100644 --- a/apps/backend/src/modules/survey/survey.module.ts +++ b/apps/backend/src/modules/survey/survey.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SurveyController } from './survey.controller'; import { SurveyService } from './survey.service'; +import { WebhookModule } from '../webhook/webhook.module'; @Module({ + imports: [WebhookModule], controllers: [SurveyController], providers: [SurveyService], exports: [SurveyService], diff --git a/apps/backend/src/modules/survey/survey.service.ts b/apps/backend/src/modules/survey/survey.service.ts index 39a0960..7687cfa 100644 --- a/apps/backend/src/modules/survey/survey.service.ts +++ b/apps/backend/src/modules/survey/survey.service.ts @@ -3,13 +3,17 @@ import { PrismaService } from '../../prisma/prisma.service'; import { CreateSurveyResponseDto } from './dto/create-survey-response.dto'; import { SurveyResponseDto } from './dto/survey-response.dto'; import { Role, Status } from '@prisma/client'; +import { WebhookService } from '../webhook/webhook.service'; @Injectable() export class SurveyService { private readonly logger = new Logger(SurveyService.name); private readonly ANONYMOUS_EMAIL = 'anonymous@survey.local'; - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly webhookService: WebhookService, + ) {} /** * Get or create the anonymous user for survey submissions @@ -130,6 +134,16 @@ export class SurveyService { // Log submission (without PII) this.logger.log(`Survey submitted: ${result.id}`); + // Dispatch webhook asynchronously (don't block response) + this.webhookService + .handleSurveySubmission(result.id, dto.q1OverallRating ?? null) + .catch((error) => { + this.logger.error( + `Webhook dispatch failed for submission ${result.id}`, + error instanceof Error ? error.stack : String(error), + ); + }); + // Return response DTO return new SurveyResponseDto( result.id, diff --git a/apps/backend/src/modules/webhook/README.md b/apps/backend/src/modules/webhook/README.md new file mode 100644 index 0000000..fb7ae4d --- /dev/null +++ b/apps/backend/src/modules/webhook/README.md @@ -0,0 +1,209 @@ +# Webhook Module + +## Overview + +The Webhook module dispatches HTTP webhooks when survey submissions occur. It sends privacy-preserving summary payloads that do NOT include full survey response data. + +**Implemented in**: STORY-053 (Summary Payload Schema) + +## Webhook Payload Schema + +When a survey is submitted, the following JSON payload is sent to the configured webhook endpoint: + +```json +{ + "submissionId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2025-12-06T19:30:45.123Z", + "submissionCount": 42, + "overallRating": 4, + "adminUrl": "http://localhost:3000/admin?submission=550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Fields + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| `submissionId` | `string` (UUID) | Unique identifier for this survey submission | `"550e8400-e29b-41d4-a716-446655440000"` | +| `timestamp` | `string` (ISO 8601) | When the survey was submitted | `"2025-12-06T19:30:45.123Z"` | +| `submissionCount` | `number` | Total number of submissions received (sequential) | `42` | +| `overallRating` | `number` or `null` | Q1 overall rating (1-5) if provided, else `null` | `4` or `null` | +| `adminUrl` | `string` (URL) | Link to view full submission in admin dashboard | `"http://localhost:3000/admin?submission=..."` | + +### Privacy Design + +**✅ Included:** +- Submission metadata (ID, timestamp, count) +- Overall rating (Q1) as optional summary metric +- Admin dashboard link for authorized access + +**❌ NOT Included:** +- Full survey responses (Q2-Q19) +- User PII (name, location, email) +- Any open-ended text responses +- Demographics data + +This ensures webhook payloads are informative without exposing sensitive data. + +## Configuration + +### Environment Variables + +Add to `.env` file: + +```bash +# Webhook endpoint (optional - if not set, webhooks are skipped) +WEBHOOK_URL=https://your-webhook-endpoint.com/webhook + +# Frontend URL for generating admin links (defaults to http://localhost:3000) +FRONTEND_URL=http://localhost:3000 +``` + +### Webhook Endpoint Requirements + +Your webhook endpoint should: + +1. **Accept POST requests** with `Content-Type: application/json` +2. **Respond with 2xx status** to indicate successful receipt +3. **Respond quickly** (< 5 seconds) - dispatch is asynchronous but should not block +4. **Handle duplicate submissions** - webhooks may retry on transient failures + +Example webhook handler (Node.js/Express): + +```javascript +app.post('/webhook', express.json(), (req, res) => { + const { submissionId, timestamp, submissionCount, overallRating, adminUrl } = req.body; + + console.log(`New survey submission: #${submissionCount}`); + console.log(`Submission ID: ${submissionId}`); + console.log(`Overall rating: ${overallRating ?? 'Not provided'}`); + console.log(`View details: ${adminUrl}`); + + // Process webhook (e.g., send Slack notification, update metrics, etc.) + + res.status(200).json({ received: true }); +}); +``` + +## Usage + +### Automatic Dispatch + +Webhooks are dispatched automatically when a survey is submitted via `POST /api/survey/submit`. No additional code is required. + +### Manual Dispatch + +To manually trigger a webhook (e.g., for testing): + +```typescript +import { WebhookService } from './modules/webhook/webhook.service'; + +// In your service/controller +async testWebhook(submissionId: string) { + await this.webhookService.handleSurveySubmission(submissionId, 4); +} +``` + +## Error Handling + +- **Webhook failures do NOT block survey submission** - submissions succeed even if webhook dispatch fails +- Failed webhooks are logged but do not throw errors +- No automatic retry mechanism (implement in your webhook endpoint if needed) +- Missing `WEBHOOK_URL` config results in skipped dispatch (logged as warning) + +## Testing + +Run webhook service tests: + +```bash +pnpm test webhook.service.spec.ts +``` + +### Test Webhook Endpoint + +Use a service like [webhook.site](https://webhook.site) or [RequestBin](https://requestbin.com) to test webhooks: + +1. Get a test webhook URL from webhook.site +2. Set `WEBHOOK_URL` in your `.env` file +3. Submit a survey +4. View the payload in webhook.site + +Example `.env`: + +```bash +WEBHOOK_URL=https://webhook.site/unique-id-here +``` + +## Integration Examples + +### Slack Notification + +```javascript +// Slack webhook endpoint +app.post('/slack-webhook', async (req, res) => { + const { submissionCount, overallRating, adminUrl } = req.body; + + const message = { + text: `📋 New survey submission (#${submissionCount})`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*New Survey Submission*\n\n*Number:* ${submissionCount}\n*Rating:* ${overallRating ? `⭐ ${overallRating}/5` : 'Not provided'}\n\n<${adminUrl}|View in Dashboard>`, + }, + }, + ], + }; + + await fetch(process.env.SLACK_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + }); + + res.sendStatus(200); +}); +``` + +### Metrics Dashboard + +```javascript +// Update real-time metrics +app.post('/metrics-webhook', async (req, res) => { + const { submissionCount, overallRating } = req.body; + + // Update dashboard + await metricsService.updateCount(submissionCount); + if (overallRating) { + await metricsService.addRating(overallRating); + } + + res.sendStatus(200); +}); +``` + +## Security Considerations + +1. **Use HTTPS** for webhook URLs in production +2. **Validate webhook signatures** if your endpoint requires authentication (not implemented in v1) +3. **Rate limit** your webhook endpoint to prevent abuse +4. **Log webhook attempts** for auditing +5. **Do NOT expose admin URLs** publicly - they are for internal use only + +## Future Enhancements + +Potential improvements for future iterations: + +- [ ] Webhook signature/authentication (HMAC) +- [ ] Automatic retry with exponential backoff +- [ ] Multiple webhook endpoints +- [ ] Webhook delivery history/logs +- [ ] Admin UI for webhook configuration +- [ ] Webhook payload versioning + +## Related Stories + +- **STORY-052**: Webhook Dispatch on Submission (dependency) +- **STORY-053**: Summary Payload Schema (this implementation) +- **STORY-054**: Webhook Configuration UI (future) diff --git a/apps/backend/src/modules/webhook/dto/webhook-payload.dto.ts b/apps/backend/src/modules/webhook/dto/webhook-payload.dto.ts new file mode 100644 index 0000000..e9ba5b7 --- /dev/null +++ b/apps/backend/src/modules/webhook/dto/webhook-payload.dto.ts @@ -0,0 +1,62 @@ +/** + * Webhook Payload DTO + * + * Summary-only payload sent to webhook endpoints when a survey is submitted. + * Privacy-preserving design: does NOT include full survey responses. + * + * Based on STORY-053: Summary Payload Schema + */ +export class WebhookPayloadDto { + /** + * Unique identifier for the survey submission + */ + submissionId: string; + + /** + * Timestamp when the survey was submitted (ISO 8601 format) + */ + timestamp: string; + + /** + * The sequential number of this submission (e.g., "3rd response") + */ + submissionCount: number; + + /** + * Overall rating (Q1) if provided - optional summary field + * Value between 1-5, or null if not provided + */ + overallRating: number | null; + + /** + * URL to view full submission details in the admin dashboard + */ + adminUrl: string; + + constructor( + submissionId: string, + timestamp: Date, + submissionCount: number, + overallRating: number | null, + adminUrl: string, + ) { + this.submissionId = submissionId; + this.timestamp = timestamp.toISOString(); + this.submissionCount = submissionCount; + this.overallRating = overallRating; + this.adminUrl = adminUrl; + } + + /** + * Convert to plain JSON object for webhook transmission + */ + toJSON(): Record { + return { + submissionId: this.submissionId, + timestamp: this.timestamp, + submissionCount: this.submissionCount, + overallRating: this.overallRating, + adminUrl: this.adminUrl, + }; + } +} diff --git a/apps/backend/src/modules/webhook/examples/webhook-payload-example.json b/apps/backend/src/modules/webhook/examples/webhook-payload-example.json new file mode 100644 index 0000000..a7ae7f2 --- /dev/null +++ b/apps/backend/src/modules/webhook/examples/webhook-payload-example.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NAM Survey Webhook Payload", + "description": "Privacy-preserving summary payload sent when a survey is submitted", + "type": "object", + "required": [ + "submissionId", + "timestamp", + "submissionCount", + "overallRating", + "adminUrl" + ], + "properties": { + "submissionId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this survey submission", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the survey was submitted", + "example": "2025-12-06T19:30:45.123Z" + }, + "submissionCount": { + "type": "integer", + "minimum": 1, + "description": "Total number of submissions received (this is the Nth response)", + "example": 42 + }, + "overallRating": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 5, + "description": "Q1 overall conference rating (1-5) if provided, otherwise null", + "example": 4 + }, + "adminUrl": { + "type": "string", + "format": "uri", + "description": "URL to view full submission details in the admin dashboard (requires authorization)", + "example": "http://localhost:3000/admin?submission=550e8400-e29b-41d4-a716-446655440000" + } + }, + "additionalProperties": false, + "examples": [ + { + "submissionId": "a1b2c3d4-e5f6-4789-a012-b3c4d5e6f7a8", + "timestamp": "2025-12-06T19:30:45.123Z", + "submissionCount": 1, + "overallRating": 5, + "adminUrl": "http://localhost:3000/admin?submission=a1b2c3d4-e5f6-4789-a012-b3c4d5e6f7a8" + }, + { + "submissionId": "f1e2d3c4-b5a6-4897-a210-c3b4e5d6f7g8", + "timestamp": "2025-12-06T20:15:22.456Z", + "submissionCount": 42, + "overallRating": null, + "adminUrl": "http://localhost:3000/admin?submission=f1e2d3c4-b5a6-4897-a210-c3b4e5d6f7g8" + } + ] +} diff --git a/apps/backend/src/modules/webhook/webhook.module.ts b/apps/backend/src/modules/webhook/webhook.module.ts new file mode 100644 index 0000000..c60f4f6 --- /dev/null +++ b/apps/backend/src/modules/webhook/webhook.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { WebhookService } from './webhook.service'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule, ConfigModule], + providers: [WebhookService], + exports: [WebhookService], +}) +export class WebhookModule {} diff --git a/apps/backend/src/modules/webhook/webhook.service.spec.ts b/apps/backend/src/modules/webhook/webhook.service.spec.ts new file mode 100644 index 0000000..0db1baa --- /dev/null +++ b/apps/backend/src/modules/webhook/webhook.service.spec.ts @@ -0,0 +1,209 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { WebhookService } from './webhook.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WebhookPayloadDto } from './dto/webhook-payload.dto'; + +describe('WebhookService', () => { + let service: WebhookService; + let prismaService: PrismaService; + let configService: ConfigService; + + const mockPrismaService = { + surveyResponse: { + count: jest.fn(), + findUnique: jest.fn(), + }, + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(WebhookService); + prismaService = module.get(PrismaService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructPayload', () => { + it('should construct valid webhook payload with all fields', async () => { + // Arrange + const submissionId = 'test-submission-id-123'; + const overallRating = 4; + const submissionCount = 42; + const createdAt = new Date('2025-12-06T19:00:00Z'); + + mockPrismaService.surveyResponse.count.mockResolvedValue(submissionCount); + mockPrismaService.surveyResponse.findUnique.mockResolvedValue({ + id: submissionId, + createdAt, + }); + mockConfigService.get.mockReturnValue('http://localhost:3000'); + + // Act + const payload = await service.constructPayload(submissionId, overallRating); + + // Assert + expect(payload).toBeInstanceOf(WebhookPayloadDto); + expect(payload.submissionId).toBe(submissionId); + expect(payload.timestamp).toBe(createdAt.toISOString()); + expect(payload.submissionCount).toBe(submissionCount); + expect(payload.overallRating).toBe(overallRating); + expect(payload.adminUrl).toBe(`http://localhost:3000/admin?submission=${submissionId}`); + }); + + it('should construct payload with null overallRating when not provided', async () => { + // Arrange + const submissionId = 'test-submission-id-456'; + const submissionCount = 10; + const createdAt = new Date('2025-12-06T20:00:00Z'); + + mockPrismaService.surveyResponse.count.mockResolvedValue(submissionCount); + mockPrismaService.surveyResponse.findUnique.mockResolvedValue({ + id: submissionId, + createdAt, + }); + mockConfigService.get.mockReturnValue('http://localhost:3000'); + + // Act + const payload = await service.constructPayload(submissionId, null); + + // Assert + expect(payload.overallRating).toBeNull(); + expect(payload.submissionId).toBe(submissionId); + }); + + it('should use custom FRONTEND_URL from config', async () => { + // Arrange + const submissionId = 'test-submission-id-789'; + const customUrl = 'https://survey.example.com'; + const createdAt = new Date('2025-12-06T21:00:00Z'); + + mockPrismaService.surveyResponse.count.mockResolvedValue(1); + mockPrismaService.surveyResponse.findUnique.mockResolvedValue({ + id: submissionId, + createdAt, + }); + mockConfigService.get.mockReturnValue(customUrl); + + // Act + const payload = await service.constructPayload(submissionId, null); + + // Assert + expect(payload.adminUrl).toBe(`${customUrl}/admin?submission=${submissionId}`); + }); + + it('should throw error when submission not found', async () => { + // Arrange + const submissionId = 'non-existent-id'; + + mockPrismaService.surveyResponse.count.mockResolvedValue(1); + mockPrismaService.surveyResponse.findUnique.mockResolvedValue(null); + + // Act & Assert + await expect(service.constructPayload(submissionId, null)).rejects.toThrow( + 'Submission not found: non-existent-id', + ); + }); + }); + + describe('WebhookPayloadDto.toJSON', () => { + it('should serialize to valid JSON structure', () => { + // Arrange + const submissionId = 'test-id'; + const timestamp = new Date('2025-12-06T19:00:00Z'); + const submissionCount = 5; + const overallRating = 5; + const adminUrl = 'http://localhost:3000/admin?submission=test-id'; + + const payload = new WebhookPayloadDto( + submissionId, + timestamp, + submissionCount, + overallRating, + adminUrl, + ); + + // Act + const json = payload.toJSON(); + + // Assert + expect(json).toEqual({ + submissionId: 'test-id', + timestamp: '2025-12-06T19:00:00.000Z', + submissionCount: 5, + overallRating: 5, + adminUrl: 'http://localhost:3000/admin?submission=test-id', + }); + }); + + it('should include null overallRating in JSON', () => { + // Arrange + const payload = new WebhookPayloadDto( + 'test-id', + new Date('2025-12-06T19:00:00Z'), + 1, + null, + 'http://localhost:3000/admin?submission=test-id', + ); + + // Act + const json = payload.toJSON(); + + // Assert + expect(json.overallRating).toBeNull(); + expect(json).toHaveProperty('overallRating'); + }); + }); + + describe('Privacy and Security', () => { + it('should NOT include any survey response data in payload', async () => { + // Arrange + const submissionId = 'privacy-test-id'; + const createdAt = new Date('2025-12-06T19:00:00Z'); + + mockPrismaService.surveyResponse.count.mockResolvedValue(1); + mockPrismaService.surveyResponse.findUnique.mockResolvedValue({ + id: submissionId, + createdAt, + }); + mockConfigService.get.mockReturnValue('http://localhost:3000'); + + // Act + const payload = await service.constructPayload(submissionId, 4); + const json = payload.toJSON(); + + // Assert - ensure only allowed fields are present + const allowedFields = ['submissionId', 'timestamp', 'submissionCount', 'overallRating', 'adminUrl']; + const actualFields = Object.keys(json); + + expect(actualFields).toEqual(expect.arrayContaining(allowedFields)); + expect(actualFields.length).toBe(allowedFields.length); + + // Ensure no sensitive data fields + expect(json).not.toHaveProperty('q2ReturnIntent'); + expect(json).not.toHaveProperty('q14LikedMost'); + expect(json).not.toHaveProperty('q19Name'); + expect(json).not.toHaveProperty('userId'); + }); + }); +}); diff --git a/apps/backend/src/modules/webhook/webhook.service.ts b/apps/backend/src/modules/webhook/webhook.service.ts new file mode 100644 index 0000000..2fc7af5 --- /dev/null +++ b/apps/backend/src/modules/webhook/webhook.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WebhookPayloadDto } from './dto/webhook-payload.dto'; + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + /** + * Generate admin dashboard URL for a specific submission + */ + private getAdminUrl(submissionId: string): string { + const baseUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + return `${baseUrl}/admin?submission=${submissionId}`; + } + + /** + * Get the total count of submitted surveys (for calculating submission number) + */ + private async getSubmissionCount(): Promise { + return this.prisma.surveyResponse.count({ + where: { status: 'SUBMITTED' }, + }); + } + + /** + * Construct webhook payload for a survey submission + * + * @param submissionId - The ID of the survey submission + * @param overallRating - The Q1 overall rating (optional) + * @returns WebhookPayloadDto containing summary information + */ + async constructPayload( + submissionId: string, + overallRating: number | null, + ): Promise { + // Get submission count + const submissionCount = await this.getSubmissionCount(); + + // Get submission timestamp + const submission = await this.prisma.surveyResponse.findUnique({ + where: { id: submissionId }, + select: { createdAt: true }, + }); + + if (!submission) { + throw new Error(`Submission not found: ${submissionId}`); + } + + // Generate admin URL + const adminUrl = this.getAdminUrl(submissionId); + + // Construct and return payload + return new WebhookPayloadDto( + submissionId, + submission.createdAt, + submissionCount, + overallRating, + adminUrl, + ); + } + + /** + * Dispatch webhook to configured endpoint + * + * @param payload - The webhook payload to send + */ + async dispatchWebhook(payload: WebhookPayloadDto): Promise { + const webhookUrl = this.configService.get('WEBHOOK_URL'); + + if (!webhookUrl) { + this.logger.warn('WEBHOOK_URL not configured, skipping webhook dispatch'); + return; + } + + try { + this.logger.log(`Dispatching webhook to ${webhookUrl}`); + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload.toJSON()), + }); + + if (!response.ok) { + throw new Error( + `Webhook dispatch failed: ${response.status} ${response.statusText}`, + ); + } + + this.logger.log(`Webhook dispatched successfully for submission ${payload.submissionId}`); + } catch (error) { + this.logger.error( + `Failed to dispatch webhook for submission ${payload.submissionId}`, + error instanceof Error ? error.stack : String(error), + ); + // Don't throw - webhook failures shouldn't block survey submission + } + } + + /** + * Handle survey submission webhook + * Constructs payload and dispatches webhook + * + * @param submissionId - The ID of the survey submission + * @param overallRating - The Q1 overall rating (optional) + */ + async handleSurveySubmission( + submissionId: string, + overallRating: number | null, + ): Promise { + try { + const payload = await this.constructPayload(submissionId, overallRating); + await this.dispatchWebhook(payload); + } catch (error) { + this.logger.error( + `Error handling survey submission webhook for ${submissionId}`, + error instanceof Error ? error.stack : String(error), + ); + // Don't throw - webhook failures shouldn't block survey submission + } + } +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index d4118b6..5596958 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -8,6 +8,20 @@ +
diff --git a/apps/frontend/src/components/ThemeToggle.tsx b/apps/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..7cc07f1 --- /dev/null +++ b/apps/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,31 @@ +import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core'; +import { IconSun, IconMoon } from '@tabler/icons-react'; + +interface ThemeToggleProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export function ThemeToggle({ size = 'lg' }: ThemeToggleProps): JSX.Element { + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true }); + + const toggleColorScheme = (): void => { + setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark'); + }; + + return ( + + {computedColorScheme === 'dark' ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 28336a7..56ce405 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -11,7 +11,7 @@ import '@mantine/notifications/styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/apps/frontend/src/pages/AdminDashboardPage.tsx b/apps/frontend/src/pages/AdminDashboardPage.tsx index 0283549..a9a19e8 100644 --- a/apps/frontend/src/pages/AdminDashboardPage.tsx +++ b/apps/frontend/src/pages/AdminDashboardPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react'; -import { Container, Stack, Title, SimpleGrid, Alert, Image } from '@mantine/core'; +import { Container, Stack, Title, SimpleGrid, Alert, Image, Group } from '@mantine/core'; import { IconAlertCircle } from '@tabler/icons-react'; import { MetricCard } from '../components/MetricCard'; import { RecentResponsesSection } from '../components/RecentResponsesSection'; +import { ThemeToggle } from '../components/ThemeToggle'; import { getAdminMetrics, getAdminRecentResponses } from '../api/admin'; import { AdminMetricsResponse, AdminRecentResponsesResponse } from '../types/admin'; @@ -40,6 +41,11 @@ export default function AdminDashboardPage() { return ( + {/* Theme Toggle */} + + + + {/* Header with Logo */} + {/* Theme Toggle */} + + + + {/* Header */} - - - Equal Experts + + {/* Theme Toggle */} + + + + + + + Equal Experts Thank You! @@ -36,6 +43,7 @@ export default function ThankYouPage() { </Button> </Stack> </Card> + </Stack> </Container> ); } \ No newline at end of file diff --git a/apps/frontend/src/theme/theme.ts b/apps/frontend/src/theme/theme.ts index 39da987..edce03c 100644 --- a/apps/frontend/src/theme/theme.ts +++ b/apps/frontend/src/theme/theme.ts @@ -96,6 +96,10 @@ export const theme: MantineThemeOverride = createTheme({ xl: '0 20px 25px rgba(0, 0, 0, 0.1)', }, + // Dark mode colors + white: '#ffffff', + black: '#1a1b1e', + components: { Button: { defaultProps: { @@ -124,7 +128,8 @@ export const theme: MantineThemeOverride = createTheme({ }, styles: { root: { - border: '1px solid #e0e0e0', + borderWidth: '1px', + borderStyle: 'solid', }, }, }, diff --git a/knowledge/product/backlog.md b/knowledge/product/backlog.md index feaf4b3..0fc534f 100644 --- a/knowledge/product/backlog.md +++ b/knowledge/product/backlog.md @@ -17,6 +17,11 @@ Master index of all stories that have been created but not yet built. Stories ar | STORY-049 | Analytics Tab | Must Have | 2025-12-02-admin-page | | STORY-050 | Sentiment Analysis Tab | Should Have | 2025-12-02-admin-page | | STORY-051 | Data Export | Should Have | 2025-12-02-admin-page | +| STORY-052 | Webhook Dispatch on Submission | Must Have | 2025-12-06-webhooks | +| STORY-053 | Summary Payload Schema | Must Have | 2025-12-06-webhooks | +| STORY-054 | Webhook Config File | Should Have | 2025-12-06-webhooks | +| STORY-055 | Webhook Error Logging | Should Have | 2025-12-06-webhooks | +| STORY-056 | Slack Payload Formatting | Could Have | 2025-12-06-webhooks | --- diff --git a/product/iterations/2025-12-02-dark-mode/story-047-implementation-prompt.md b/product/iterations/2025-12-02-dark-mode/story-047-implementation-prompt.md new file mode 100644 index 0000000..79df399 --- /dev/null +++ b/product/iterations/2025-12-02-dark-mode/story-047-implementation-prompt.md @@ -0,0 +1,163 @@ +# Implementation Prompt: Dark Mode for Reduced Eye Strain + +**Story ID**: STORY-047 +**Status**: Ready for Implementation + +## Task Description + +Implement dark mode functionality for the NAM Conference Survey application with automatic system preference detection, manual toggle, and persistent user preferences. + +## Implementation Prompt + +Implement a complete dark mode feature for the survey application that allows users to view the survey in a dark color scheme. The implementation must: + +1. **Detect System Preferences**: Automatically detect and apply the user's system-level dark mode preference on first visit using `prefers-color-scheme` media query. + +2. **Manual Toggle**: Provide a theme toggle button/switch that allows users to manually switch between light and dark modes. The toggle should: + - Be accessible via keyboard navigation + - Include proper ARIA labels for screen readers + - Be visually distinct and easy to find + - Show the current theme state clearly + +3. **Persistent Preferences**: Store the user's theme preference in localStorage and apply it on subsequent visits, overriding system preferences if manually set. + +4. **No Flash on Load**: Implement theme loading in a way that prevents the "flash of wrong theme" on page load. This typically requires: + - Inline script in the HTML head to apply theme before React renders + - Or use Mantine's ColorSchemeScript component + +5. **Dark Mode Color Palette**: Define a dark mode color palette that: + - Uses Equal Experts brand colors where appropriate (Primary Blue: #1795d4, Navy: #22567c, Charcoal: #2c3234) + - Meets WCAG AA contrast requirements (4.5:1 for normal text, 3:1 for large text) + - Provides clear focus indicators + - Works well with the existing Mantine component library + +6. **Apply Theme Across All Components**: Ensure all UI components adapt to dark mode: + - Survey form pages + - Thank you page + - Admin dashboard page + - All question components (Likert, Multiple Select, Ranking, Open-ended, etc.) + - Progress indicators + - Buttons and interactive elements + - Cards and containers + +7. **Accessibility Requirements**: + - All text must meet WCAG AA contrast requirements in dark mode + - Focus indicators must be clearly visible in dark mode + - Theme toggle must announce state changes to screen readers + - Theme switching must be instant without layout shift + +8. **Testing**: Verify the implementation works correctly across: + - Chrome, Safari, Firefox, Edge + - Light mode, dark mode, and system preference auto-detection + - Page refreshes and navigation between pages + - Keyboard-only navigation + +## Technical Approach + +Since the application uses Mantine UI v7, leverage Mantine's built-in dark mode support: + +1. Use `MantineProvider` with `theme` and `defaultColorScheme` props +2. Implement `ColorSchemeScript` in the HTML head to prevent flash +3. Use `useMantineColorScheme()` hook to manage theme state +4. Create a theme toggle component using `ActionIcon` or `Switch` from Mantine +5. Use Mantine's color scheme utilities (`c`, `lighten`, `darken`) for dynamic colors +6. Define custom dark mode colors in the theme override if needed + +## Acceptance Criteria + +**Scenario 1: System Preference Detection (Happy Path)** +- Given a user has dark mode enabled in their device settings +- When they open the survey for the first time +- Then the survey automatically displays in dark mode +- And all screens use consistent dark mode styling + +**Scenario 2: Manual Theme Toggle** +- Given a user is viewing the survey +- When they click the theme toggle +- Then the theme switches between light and dark mode +- And the preference persists across page loads + +**Scenario 3: No Flash on Load** +- Given a user has dark mode preference saved +- When they load any survey page +- Then the page renders in dark mode immediately without flashing light theme first + +## Quality Checklist + +- [ ] Dark mode colors have sufficient contrast for readability +- [ ] All UI components work correctly in both modes +- [ ] Theme toggle announces state change to screen readers +- [ ] Works across Chrome, Safari, Firefox, Edge +- [ ] Accessibility: All text meets WCAG AA contrast requirements in dark mode +- [ ] Accessibility: Focus indicators clearly visible in dark mode +- [ ] Performance: Theme switching is instant without layout shift +- [ ] Usability: Theme toggle is keyboard accessible and screen reader friendly + +## Files Affected + +**Core Theme Configuration**: +- `apps/frontend/src/theme/theme.ts` - Add dark mode color overrides +- `apps/frontend/src/main.tsx` - Configure MantineProvider with color scheme support +- `apps/frontend/index.html` - Add ColorSchemeScript to prevent flash + +**New Components**: +- `apps/frontend/src/components/ThemeToggle.tsx` - Create theme toggle component (NEW) + +**Updated Components** (ensure dark mode compatibility): +- `apps/frontend/src/pages/SurveyPage.tsx` +- `apps/frontend/src/pages/ThankYouPage.tsx` +- `apps/frontend/src/pages/AdminDashboardPage.tsx` +- `apps/frontend/src/components/ProgressIndicator.tsx` +- `apps/frontend/src/components/QuestionRenderer.tsx` +- `apps/frontend/src/components/MetricCard.tsx` +- `apps/frontend/src/components/RecentResponsesSection.tsx` +- All question components in `apps/frontend/src/components/questions/` + +## Important Implementation Notes + +1. Follow the project's development guidelines: + - Use TypeScript with explicit types + - Follow React rules (functional components, proper hooks usage) + - Use Mantine UI components exclusively + - Maintain mobile-first responsive design + +2. Consider adding these color scheme options: + - `'light'` - Light mode + - `'dark'` - Dark mode + - `'auto'` - Follow system preference (optional enhancement) + +3. Equal Experts branding must remain consistent: + - Use the same logo URL: `https://www.equalexperts.com/wp-content/uploads/2024/10/2024-Logo.svg` + - Logo may need different styling/filters in dark mode for visibility + +4. The theme toggle should be placed in a consistent location across all pages (e.g., top-right corner) + +## Open Questions to Address + +- Should we provide an "auto" option that always follows system preference? +- Does Equal Experts have dark mode logo variants to use? (If not, use CSS filters for logo visibility) + +## Dependencies + +- MVP survey UI must be complete (stories 019-039) ✓ +- Mantine UI v7.17.8+ installed ✓ + +## Execution Workflow + +When implementing this story: +1. Read all files listed in "Files Affected" to understand current implementation +2. Start with core theme configuration and ColorSchemeScript +3. Create the ThemeToggle component +4. Test theme switching works without flash +5. Update each page and component to ensure dark mode compatibility +6. Run the application and verify all acceptance criteria +7. Test accessibility with keyboard navigation and screen reader +8. Test across different browsers +9. Ask for review before committing + +## Rules to Follow + +Consider the following rules during implementation: +- `rules/react-rules.md` +- `rules/typescript-rules.md` +- `CLAUDE.md` (Equal Experts branding guidelines) diff --git a/product/iterations/2025-12-06-sandbox-js1/discovery/.synthesis-pending b/product/iterations/2025-12-06-sandbox-js1/discovery/.synthesis-pending new file mode 100644 index 0000000..e69de29 diff --git a/product/iterations/2025-12-06-sandbox-js1/discovery/README.md b/product/iterations/2025-12-06-sandbox-js1/discovery/README.md new file mode 100644 index 0000000..e037168 --- /dev/null +++ b/product/iterations/2025-12-06-sandbox-js1/discovery/README.md @@ -0,0 +1,38 @@ +# Iteration Discovery: 2025-12-06-sandbox-js1 + +**Started**: 2025-12-06 +**Focus**: Jeremy temporary testing of the product iteration process +**Status**: Complete (sandbox) + +## Goals +- Learn the product iteration framework before real feature work +- Practice the interview/discovery process +- Understand how to create robust stories (1-3) for a small dev team + +## Research Methods +- [x] AI-guided stakeholder interview + +## Timeline +- **Start**: 2025-12-06 +- **Completed**: 2025-12-06 + +## Key Decisions +1. Scope limited to interview process + story creation (no story mapping or Jira integration needed) +2. Identified webhooks as the feature for the next real iteration +3. Team preference: backend-focused features + +## Findings + +### Framework Feedback +- Freeform Q&A format works well - can be shaped in real time +- No gaps identified in the process + +### Next Iteration +- Feature: Response webhooks (POST to external URL when surveys submitted) +- Rationale: Backend-focused, interesting to present, practical scope for small team + +## Interviews +- [interview-jeremy-2025-12-06.md](interviews/interview-jeremy-2025-12-06.md) + +## Notes +Sandbox iteration completed successfully. Ready to start dedicated webhooks iteration. diff --git a/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-jeremy-2025-12-06.md b/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-jeremy-2025-12-06.md new file mode 100644 index 0000000..0a41b57 --- /dev/null +++ b/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-jeremy-2025-12-06.md @@ -0,0 +1,63 @@ +# Interview: Jeremy - Sandbox Iteration + +## Metadata +- **Date**: 2025-12-06 +- **Participant**: Jeremy +- **Role**: Workshop participant / Developer +- **Duration**: ~10 minutes +- **Interviewer**: Claude (AI-guided) +- **Iteration**: 2025-12-06-sandbox-js1 + +## Context +Sandbox iteration to learn the product iteration framework before defining a real feature iteration. + +## Key Findings + +### Business Context +- Participating in a workshop using this framework +- Planning to define an actual feature iteration for the survey website soon +- First priority: learn how to use the framework to develop stories + +### Learning Goals +- Primary focus: Interview/discovery process +- Secondary focus: Creating 1-3 robust stories for a small dev team +- Not needed: Higher-scale features like story mapping or Jira integration +- Target output: Robust stories describing a feature update + +### Team Context +- Small development team +- Team skews toward backend engineering +- Looking for something interesting to present to others +- Needs to be doable quickly + +### Feature Exploration +- Discussed potential features: Analytics API, CSV/PDF Export, Scheduled Survey Windows, Response Webhooks +- **Selected for next iteration**: Webhooks feature +- Rationale: Backend-focused, interesting to present, practical scope + +### Framework Feedback +- **What worked well**: Freeform Q&A format that can be shaped in real time +- **Gaps identified**: None at this point + +## Direct Quotes +> "I like that the framework use mixes in freeform Q&A, we were able to shape this in real time." + +> "Just want to build a robust new 1-3 stories." + +## Insights & Observations +1. The conversational interview format resonated well - flexibility is valued +2. Practical, focused scope preferred over comprehensive framework features +3. Backend preference aligns with team composition +4. Webhooks feature offers good learning vehicle: new NestJS module, external integrations, event-driven patterns + +## Open Questions +- Specific webhook requirements (to be explored in dedicated webhooks iteration) +- Team's familiarity with event-driven patterns + +## Next Steps +- [x] Complete sandbox iteration +- [ ] Start new iteration: `webhooks` +- [ ] Conduct focused discovery interview on webhook requirements + +## Tags +`sandbox` `learning` `framework-evaluation` `workshop` diff --git a/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-template.md b/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-template.md new file mode 100644 index 0000000..555c724 --- /dev/null +++ b/product/iterations/2025-12-06-sandbox-js1/discovery/interviews/interview-template.md @@ -0,0 +1,113 @@ +# Interview: [Participant Name/Role] + +**Date**: YYYY-MM-DD +**Participant**: [Name or role] +**Interviewer**: [Name] +**Duration**: [X minutes] +**Format**: [In-person / Video / Phone] + +--- + +## Context + +**Participant Background**: +- Role: [Title/Role] +- Experience: [Relevant experience] +- Relationship to product: [User/Stakeholder/Expert] + +**Interview Goals**: +- [Goal 1] +- [Goal 2] + +--- + +## Key Findings + +### Theme 1: [Name] + +**Finding**: [Summary of finding] + +**Evidence**: +> "[Direct quote from participant]" + +**Impact**: [High/Medium/Low] + +--- + +### Theme 2: [Name] + +**Finding**: [Summary of finding] + +**Evidence**: +> "[Direct quote from participant]" + +**Impact**: [High/Medium/Low] + +--- + +## Pain Points + +| Pain Point | Severity | Frequency | Quote | +|------------|----------|-----------|-------| +| [Pain 1] | High/Med/Low | Daily/Weekly/Rare | "[Quote]" | +| [Pain 2] | High/Med/Low | Daily/Weekly/Rare | "[Quote]" | + +--- + +## Workflows Discussed + +### Workflow: [Name] + +**Current State**: +1. [Step 1] +2. [Step 2] +3. [Step 3] + +**Pain Points in Workflow**: +- [Issue 1] +- [Issue 2] + +**Desired State**: +- [Improvement 1] +- [Improvement 2] + +--- + +## Feature Ideas/Requests + +| Feature | Priority (Participant) | Notes | +|---------|------------------------|-------| +| [Feature 1] | Must have | [Context] | +| [Feature 2] | Nice to have | [Context] | + +--- + +## Open Questions + +- [ ] [Question that needs follow-up] +- [ ] [Question for next interview] + +--- + +## Insights & Observations + +**Behavioral Observations**: +- [What you noticed about how they work] + +**Implicit Needs**: +- [Needs they didn't explicitly state but showed] + +**Surprising Findings**: +- [Anything unexpected] + +--- + +## Tags + +`#[persona]` `#[theme1]` `#[theme2]` `#[pain-point]` + +--- + +## Raw Notes + +[Detailed notes from the interview - can be rough, will be synthesized later] diff --git a/product/iterations/2025-12-06-webhooks/discovery/README.md b/product/iterations/2025-12-06-webhooks/discovery/README.md new file mode 100644 index 0000000..61baf2c --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/discovery/README.md @@ -0,0 +1,50 @@ +# Iteration Discovery: 2025-12-06-webhooks + +**Started**: 2025-12-06 +**Focus**: Response webhooks for external system notifications when surveys are submitted +**Status**: Active + +## Goals +- Notify external systems when survey responses are submitted +- Support multiple integration targets: Slack, IFTTT, webhook.site +- Privacy-preserving: summary payload with link to admin for details + +## Scope + +### In Scope (MVP) +- Webhook service that fires on survey submission +- Config file for webhook URL configuration +- Summary payload (key fields + admin link) +- Fire-and-forget with error logging +- Support for multiple simultaneous destinations + +### In Scope (v1.0) +- Basic retry (1-2 attempts) on failure + +### Backlog (v2) +- Admin UI for webhook configuration +- Advanced retry with queue/backoff +- Security (shared secrets, HMAC signatures) + +## Research Methods +- [x] AI-guided stakeholder interview + +## Timeline +- **Start**: 2025-12-06 +- **Target synthesis**: 2025-12-06 + +## Key Decisions +1. Summary payload mode (not full data) for privacy +2. Config file over env vars or admin UI for v1 +3. Fire-and-forget for MVP, basic retry for v1.0 +4. No authentication/signing for MVP +5. Three integration stories: Slack, IFTTT, webhook.site + +## Success Criteria +> "I can submit a survey and see the webhook hit webhook.site within seconds" + +## Interviews +- [interview-jeremy-2025-12-06.md](interviews/interview-jeremy-2025-12-06.md) + +## Notes +Backend-focused feature for small dev team. Demo-friendly scope with clear visual success criteria. diff --git a/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-jeremy-2025-12-06.md b/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-jeremy-2025-12-06.md new file mode 100644 index 0000000..84c295d --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-jeremy-2025-12-06.md @@ -0,0 +1,77 @@ +# Interview: Jeremy - Webhooks Feature + +## Metadata +- **Date**: 2025-12-06 +- **Participant**: Jeremy +- **Role**: Developer / Product Owner +- **Duration**: ~5 minutes +- **Interviewer**: Claude (AI-guided) +- **Iteration**: 2025-12-06-webhooks + +## Context +Defining webhook functionality to notify external systems when survey responses are submitted. Backend-focused feature for a small dev team learning exercise. + +## Key Findings + +### Use Cases (3 separate stories identified) +1. **Slack channel** - Real-time notifications to a Slack channel +2. **IFTTT / event routing** - Integration with IFTTT or equivalent event routing service +3. **Demo & testing** - webhook.site for demos and unit testing + +### Payload Design +- **Mode**: Summary (not full response data) +- **Contents**: Key fields + link back to admin site for full details +- **Rationale**: Privacy preservation - sensitive data stays in the app + +### Configuration +- **MVP/v1**: Config file (JSON/YAML listing webhook destinations) +- **Backlog/v2**: Admin UI for managing webhook URLs +- **Multiple destinations**: Supported (Slack AND IFTTT simultaneously) + +### Error Handling +- **MVP**: Fire and forget, log errors to existing logging framework +- **v1.0**: Basic retry (1-2 attempts), same error logging +- **Deferred**: Queue with backoff (not in scope) + +### Security +- **Approach**: None for MVP +- **Rationale**: Keep it simple, rely on obscure URLs +- **Deferred**: Shared secret headers, HMAC signatures (not in scope) + +### Success Criteria +> "I can submit a survey and see the webhook hit webhook.site within seconds" + +Demo-focused: visual proof that the webhook fires and delivers payload to external service. + +## Direct Quotes +> "Summary mode, with link back to admin site to see full details, so privacy is preserved" + +> "Version 1: config file. Version 2 for backlog: admin UI" + +> "MVP: fire & forget, but log errors to the logging framework used by the site. v1.0: basic retry, same error logging." + +## Insights & Observations +1. Clear separation of MVP vs v1.0 vs backlog scope +2. Privacy-conscious design - summary only, full data stays in app +3. Three distinct integration targets = three stories +4. Success measured by visual demo, not complex metrics +5. Backend-focused team, appropriate technical choices + +## Suggested Story Breakdown +1. **Core webhook service** - Fire webhook on submission, config file, fire-and-forget +2. **Slack integration** - Format payload for Slack incoming webhook +3. **IFTTT integration** - Format payload for IFTTT webhook trigger +4. **Demo/test mode** - webhook.site integration for testing + +## Open Questions +- Specific fields to include in summary payload? +- Config file format preference (JSON vs YAML)? +- Slack message formatting preferences? + +## Technical Notes +- Existing stack: NestJS + Prisma +- Logging framework already in place +- Admin dashboard exists (can link to it from webhook payload) + +## Tags +`webhooks` `backend` `integrations` `slack` `ifttt` `mvp` diff --git a/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-template.md b/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-template.md new file mode 100644 index 0000000..555c724 --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/discovery/interviews/interview-template.md @@ -0,0 +1,113 @@ +# Interview: [Participant Name/Role] + +**Date**: YYYY-MM-DD +**Participant**: [Name or role] +**Interviewer**: [Name] +**Duration**: [X minutes] +**Format**: [In-person / Video / Phone] + +--- + +## Context + +**Participant Background**: +- Role: [Title/Role] +- Experience: [Relevant experience] +- Relationship to product: [User/Stakeholder/Expert] + +**Interview Goals**: +- [Goal 1] +- [Goal 2] + +--- + +## Key Findings + +### Theme 1: [Name] + +**Finding**: [Summary of finding] + +**Evidence**: +> "[Direct quote from participant]" + +**Impact**: [High/Medium/Low] + +--- + +### Theme 2: [Name] + +**Finding**: [Summary of finding] + +**Evidence**: +> "[Direct quote from participant]" + +**Impact**: [High/Medium/Low] + +--- + +## Pain Points + +| Pain Point | Severity | Frequency | Quote | +|------------|----------|-----------|-------| +| [Pain 1] | High/Med/Low | Daily/Weekly/Rare | "[Quote]" | +| [Pain 2] | High/Med/Low | Daily/Weekly/Rare | "[Quote]" | + +--- + +## Workflows Discussed + +### Workflow: [Name] + +**Current State**: +1. [Step 1] +2. [Step 2] +3. [Step 3] + +**Pain Points in Workflow**: +- [Issue 1] +- [Issue 2] + +**Desired State**: +- [Improvement 1] +- [Improvement 2] + +--- + +## Feature Ideas/Requests + +| Feature | Priority (Participant) | Notes | +|---------|------------------------|-------| +| [Feature 1] | Must have | [Context] | +| [Feature 2] | Nice to have | [Context] | + +--- + +## Open Questions + +- [ ] [Question that needs follow-up] +- [ ] [Question for next interview] + +--- + +## Insights & Observations + +**Behavioral Observations**: +- [What you noticed about how they work] + +**Implicit Needs**: +- [Needs they didn't explicitly state but showed] + +**Surprising Findings**: +- [Anything unexpected] + +--- + +## Tags + +`#[persona]` `#[theme1]` `#[theme2]` `#[pain-point]` + +--- + +## Raw Notes + +[Detailed notes from the interview - can be rough, will be synthesized later] diff --git a/product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md b/product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md new file mode 100644 index 0000000..d54b350 --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md @@ -0,0 +1,233 @@ +# Discovery Synthesis: Webhooks + +**Synthesis Date**: 2025-12-06 +**Iteration**: 2025-12-06-webhooks +**Research Period**: 2025-12-06 +**Status**: Complete + +--- + +## Executive Summary + +The webhooks iteration enables external system notifications when survey responses are submitted. The feature serves three integration targets: Slack channels for real-time team visibility, IFTTT/event routing services for workflow automation, and webhook.site for development testing. The design prioritizes privacy (summary payload with admin link rather than full data) and simplicity (config file over admin UI, fire-and-forget over complex retry logic). MVP scope is intentionally minimal to enable quick delivery by a small backend-focused team. + +--- + +## Research Overview + +**Interviews Conducted**: 1 +**Observations**: 0 +**Other Sources**: Product spec, previous iteration synthesis + +### Participants + +| # | Role | Date | Key Focus | +|---|------|------|-----------| +| 1 | Developer / Product Owner | 2025-12-06 | Feature requirements, scope definition | + +--- + +## Key Themes + +### Theme 1: External System Integration + +**Summary**: Survey submissions should trigger notifications to external systems for real-time visibility and workflow automation. + +**Evidence**: +- Interview: "To a Slack channel" - for real-time team notifications +- Interview: "To IFTTT or equivalent event routing service" - for workflow automation +- Interview: "For demo & unit test to webhook.site" - for development verification + +**Impact**: High + +**User Need**: When a survey is submitted, I want external systems to be notified so that stakeholders have real-time visibility and can trigger automated workflows. + +--- + +### Theme 2: Privacy-Preserving Design + +**Summary**: Webhook payloads should contain summary data with links to full details, rather than complete response data. + +**Evidence**: +- Interview: "Summary mode, with link back to admin site to see full details, so privacy is preserved" + +**Impact**: High + +**User Need**: When webhooks fire, I want only summary information sent externally so that sensitive feedback data stays within the application and privacy is maintained. + +--- + +### Theme 3: Simple Configuration + +**Summary**: Webhook destinations should be configured via config file for v1, with admin UI deferred to backlog. + +**Evidence**: +- Interview: "Version 1: config file. Version 2 for backlog: admin UI" + +**Impact**: Medium + +**User Need**: When setting up webhooks, I want to configure destinations through a simple config file so that setup is straightforward without building additional UI. + +--- + +### Theme 4: Reliability vs. Simplicity Tradeoff + +**Summary**: MVP prioritizes simplicity (fire-and-forget) over reliability (retry logic), with error logging for visibility. + +**Evidence**: +- Interview: "MVP: fire & forget, but log errors to the logging framework used by the site. v1.0: basic retry, same error logging." + +**Impact**: Medium + +**User Need**: When webhooks fail, I want errors logged to the existing framework so that I have visibility into failures without complex retry infrastructure. + +--- + +## Pain Points (Ranked) + +| Rank | Pain Point | Severity | Frequency | Users Affected | +|------|------------|----------|-----------|----------------| +| 1 | No real-time notification when surveys are submitted | High | Every submission | Conference Organizer | +| 2 | Cannot integrate survey events with external workflows | Medium | Per integration need | Conference Organizer | +| 3 | No easy way to verify webhook functionality during development | Medium | During development | Developer | + +--- + +## User Needs + +### Must Address +1. **Webhook delivery on submission**: System must POST to configured URLs when surveys are submitted - Evidence: Core feature requirement from interview +2. **Summary payload format**: Webhook payload must include key fields and admin link, not full response data - Evidence: "Summary mode... so privacy is preserved" +3. **Multiple destination support**: System must support multiple simultaneous webhook destinations - Evidence: Three distinct integration targets identified + +### Should Address +1. **Error logging**: Failed webhook calls should be logged to existing logging framework - Evidence: "Log errors to the logging framework used by the site" +2. **Config file management**: Webhook URLs configurable via JSON/YAML config file - Evidence: "Version 1: config file" + +### Could Address +1. **Basic retry logic**: 1-2 retry attempts on failure (v1.0 scope) - Evidence: "v1.0: basic retry" +2. **Admin UI configuration**: Web interface for managing webhooks (backlog) - Evidence: "Version 2 for backlog: admin UI" + +--- + +## Proposed Features + +### Feature 1: Core Webhook Service + +**User Story**: As a conference organizer, I want the system to send webhook notifications when surveys are submitted so that I have real-time visibility into survey activity. + +**Addresses**: +- Theme: External System Integration +- Pain Points: 1, 2 +- User Needs: Must Address 1, 2, 3 + +**Estimated Effort**: M + +**Priority**: Must Have + +--- + +### Feature 2: Slack Integration + +**User Story**: As a conference organizer, I want survey submissions to post to a Slack channel so that the team has real-time visibility without checking the admin dashboard. + +**Addresses**: +- Theme: External System Integration +- Pain Points: 1 +- User Needs: Must Address 1 + +**Estimated Effort**: S + +**Priority**: Must Have + +--- + +### Feature 3: IFTTT / Event Routing Integration + +**User Story**: As a conference organizer, I want survey submissions to trigger IFTTT webhooks so that I can automate workflows based on survey events. + +**Addresses**: +- Theme: External System Integration +- Pain Points: 2 +- User Needs: Must Address 1 + +**Estimated Effort**: S + +**Priority**: Should Have + +--- + +### Feature 4: Development Testing Support + +**User Story**: As a developer, I want to configure webhooks to point to webhook.site so that I can verify webhook functionality during development and testing. + +**Addresses**: +- Theme: External System Integration +- Pain Points: 3 +- User Needs: Must Address 1, 3 + +**Estimated Effort**: S + +**Priority**: Must Have + +--- + +## Cross-Iteration References + +**Related Previous Work**: +- **2025-12-02-admin-page**: Admin dashboard provides the destination for "link back to admin" in webhook payload. Dashboard route `/admin` established. +- **2025-11-12-mvp**: Survey submission flow and data model established. Webhook service will integrate with submission handler. + +**Potential Conflicts**: +- None identified. Webhooks extend existing functionality without modifying it. + +--- + +## Recommendations + +### Immediate Actions (This Iteration) +1. Create webhook service module in NestJS backend +2. Define summary payload schema (key fields + admin URL) +3. Implement config file loading for webhook destinations +4. Integrate webhook dispatch into survey submission flow +5. Add error logging for failed webhook calls + +### Future Considerations +1. **v1.0**: Add basic retry logic (1-2 attempts with short delay) +2. **v2 (Backlog)**: Admin UI for webhook configuration +3. **v2 (Backlog)**: Security features (shared secrets, HMAC signatures) + +--- + +## Open Questions + +- [x] What events trigger webhooks? → Survey submission only (for MVP) +- [x] What data in payload? → Summary fields + admin link +- [x] How configured? → Config file (JSON/YAML) +- [x] Error handling? → Fire-and-forget with logging +- [x] Security? → None for MVP +- [ ] Specific fields to include in summary payload? (To be determined during implementation) +- [ ] Config file format preference (JSON vs YAML)? (To be determined) +- [ ] Slack message formatting preferences? (To be determined) + +--- + +## Appendix + +### Research Artifacts +- [Interview: Jeremy 2025-12-06](../interviews/interview-jeremy-2025-12-06.md) + +### Methodology Notes +- Single stakeholder interview conducted as part of learning exercise +- Scope intentionally minimal for small backend team with limited time +- Success criteria focused on demonstrable outcome (visual webhook delivery) + +### Key Quotes + +> "Summary mode, with link back to admin site to see full details, so privacy is preserved" + +> "Version 1: config file. Version 2 for backlog: admin UI" + +> "MVP: fire & forget, but log errors to the logging framework used by the site. v1.0: basic retry, same error logging." + +> Success criteria: "I can submit a survey and see the webhook hit webhook.site within seconds" diff --git a/product/iterations/2025-12-06-webhooks/stories/stories-index.md b/product/iterations/2025-12-06-webhooks/stories/stories-index.md new file mode 100644 index 0000000..ef77e95 --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/stories-index.md @@ -0,0 +1,72 @@ +# Stories Index: 2025-12-06-webhooks + +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Total Stories**: 5 + +## Summary + +| Priority | Count | +|----------|-------| +| Must have | 2 | +| Should have | 2 | +| Could have | 1 | + +**Total Estimated Effort**: S + 4×XS = ~2-3 days + +## Stories + +| ID | Title | Priority | Size | Status | +|----|-------|----------|------|--------| +| STORY-052 | Webhook Dispatch on Submission | Must have | S | Draft | +| STORY-053 | Summary Payload Schema | Must have | XS | Draft | +| STORY-054 | Webhook Config File | Should have | XS | Draft | +| STORY-055 | Webhook Error Logging | Should have | XS | Draft | +| STORY-056 | Slack Payload Formatting | Could have | XS | Draft | + +## Quick Start + +**Minimum demo (STORY-052 only)**: Submit survey → see webhook hit webhook.site + +## Story Details + +### STORY-052: Webhook Dispatch on Submission +**File**: [story-052-webhook-dispatch-on-submission.md](story-052-webhook-dispatch-on-submission.md) + +Minimal foundation: HTTP POST fires when survey submitted. Can use hardcoded URL for demo. + +### STORY-053: Summary Payload Schema +**File**: [story-053-summary-payload-schema.md](story-053-summary-payload-schema.md) + +Define payload with summary fields and admin link. Privacy-preserving. + +### STORY-054: Webhook Config File +**File**: [story-054-webhook-config-file.md](story-054-webhook-config-file.md) + +Load webhook URLs from JSON/YAML config file. Supports multiple destinations. + +### STORY-055: Webhook Error Logging +**File**: [story-055-webhook-error-logging.md](story-055-webhook-error-logging.md) + +Log webhook success/failure to NestJS logging framework. + +### STORY-056: Slack Payload Formatting +**File**: [story-056-slack-payload-formatting.md](story-056-slack-payload-formatting.md) + +Slack-specific message formatting for readable channel notifications. + +## Dependencies + +``` +STORY-052 (Webhook Dispatch) ─┬─► STORY-053 (Payload Schema) + ├─► STORY-054 (Config File) ──► STORY-056 (Slack) + └─► STORY-055 (Error Logging) +``` + +## Recommended Build Order + +1. **STORY-052** - Core dispatch (demo-able immediately) +2. **STORY-053** - Better payload +3. **STORY-054** - Config file (enables multiple webhooks) +4. **STORY-055** - Error logging (observability) +5. **STORY-056** - Slack formatting (nice to have) diff --git a/product/iterations/2025-12-06-webhooks/stories/story-052-webhook-dispatch-on-submission.md b/product/iterations/2025-12-06-webhooks/stories/story-052-webhook-dispatch-on-submission.md new file mode 100644 index 0000000..b8720d4 --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/story-052-webhook-dispatch-on-submission.md @@ -0,0 +1,64 @@ +# User Story: Webhook Dispatch on Submission + +**Story ID**: STORY-052 +**Iteration**: 2025-12-06-webhooks +**Priority**: Must have +**Status**: Draft +**Labels**: 2025-12-06-webhooks, conference-organizer, backend, integrations, llm-dev + +## User Story +As a conference organizer, +I want an HTTP POST request sent to an external URL when a survey is submitted, +So that I can verify webhook functionality works end-to-end. + +## Context +This is the minimal foundation for webhook notifications. It proves the core mechanism works: survey submission triggers an outbound HTTP call. Initially uses a hardcoded URL (e.g., webhook.site) for testing. Config file and payload refinement come in subsequent stories. + +## Source +**Discovery Cycle**: 2025-12-06-webhooks +**Synthesis Reference**: `product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md` +**User Need**: Verify webhooks fire when surveys are submitted +**Supporting Evidence**: Success criteria: "I can submit a survey and see the webhook hit webhook.site within seconds" + +## Acceptance Criteria + +### Functional Scenarios + +**Scenario 1: Webhook fires on submission** +- **Given** a survey response is being submitted +- **When** the submission completes successfully +- **Then** an HTTP POST request is sent to the configured webhook URL +- **And** the request includes basic JSON payload (submission ID, timestamp) + +**Scenario 2: Submission succeeds regardless of webhook** +- **Given** the webhook URL is unreachable or returns an error +- **When** a survey is submitted +- **Then** the survey submission still completes successfully +- **And** the user sees the normal thank you screen + +### Non-Functional Requirements +- [ ] Performance: Webhook call is async, doesn't block submission response +- [ ] Reliability: Survey submission never fails due to webhook issues + +### Quality Checklist +- [ ] Submitting survey triggers visible request on webhook.site +- [ ] Survey submission UX is unchanged +- [ ] Basic payload is valid JSON + +## Open Questions +- None - this is intentionally minimal + +## Dependencies +- Survey submission endpoint (exists) + +## Estimate +**Size**: S +**Confidence**: High + +**Reasoning**: Minimal scope - just add HTTP client call to existing submission flow. No config, no complex payload, no error handling beyond fire-and-forget. + +## Metadata +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Last Updated**: 2025-12-06 +**Build Date**: diff --git a/product/iterations/2025-12-06-webhooks/stories/story-053-summary-payload-schema.md b/product/iterations/2025-12-06-webhooks/stories/story-053-summary-payload-schema.md new file mode 100644 index 0000000..94e03c8 --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/story-053-summary-payload-schema.md @@ -0,0 +1,64 @@ +# User Story: Summary Payload Schema + +**Story ID**: STORY-053 +**Iteration**: 2025-12-06-webhooks +**Priority**: Must have +**Status**: Draft +**Labels**: 2025-12-06-webhooks, conference-organizer, backend, integrations, llm-dev + +## User Story +As a conference organizer, +I want the webhook payload to include summary information and a link to the admin dashboard, +So that I can quickly understand the submission without exposing full response data. + +## Context +Webhook payloads should be informative but privacy-preserving. This story defines and implements the summary payload schema with key fields and an admin link for full details. + +## Source +**Discovery Cycle**: 2025-12-06-webhooks +**Synthesis Reference**: `product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md` +**User Need**: "Summary mode, with link back to admin site to see full details, so privacy is preserved" +**Supporting Evidence**: Interview explicitly requested summary-only payload + +## Acceptance Criteria + +### Functional Scenarios + +**Scenario 1: Payload includes summary fields** +- **Given** a survey is submitted +- **When** the webhook fires +- **Then** the payload includes: submission ID, timestamp, submission count (nth response) +- **And** the payload does NOT include full survey answers + +**Scenario 2: Payload includes admin link** +- **Given** a survey is submitted +- **When** the webhook fires +- **Then** the payload includes a URL to view details in the admin dashboard +- **And** the URL is correctly formatted and functional + +### Non-Functional Requirements +- [ ] Privacy: No sensitive survey data in webhook payload +- [ ] Usability: Payload is human-readable JSON + +### Quality Checklist +- [ ] Payload schema is documented +- [ ] Admin link works when clicked +- [ ] No PII or survey answers exposed + +## Open Questions +- Include overall rating if provided? (optional field) + +## Dependencies +- STORY-052: Webhook Dispatch on Submission + +## Estimate +**Size**: XS +**Confidence**: High + +**Reasoning**: Just defining and implementing a JSON schema. Straightforward data selection and formatting. + +## Metadata +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Last Updated**: 2025-12-06 +**Build Date**: diff --git a/product/iterations/2025-12-06-webhooks/stories/story-054-webhook-config-file.md b/product/iterations/2025-12-06-webhooks/stories/story-054-webhook-config-file.md new file mode 100644 index 0000000..833d1bf --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/story-054-webhook-config-file.md @@ -0,0 +1,70 @@ +# User Story: Webhook Config File + +**Story ID**: STORY-054 +**Iteration**: 2025-12-06-webhooks +**Priority**: Should have +**Status**: Draft +**Labels**: 2025-12-06-webhooks, conference-organizer, backend, integrations, llm-dev + +## User Story +As a conference organizer, +I want to configure webhook URLs in a config file, +So that I can change webhook destinations without modifying code. + +## Context +Moves webhook URL from hardcoded value to external configuration. Supports multiple webhook destinations. Uses JSON or YAML config file loaded at startup. + +## Source +**Discovery Cycle**: 2025-12-06-webhooks +**Synthesis Reference**: `product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md` +**User Need**: "Version 1: config file" for webhook configuration +**Supporting Evidence**: Interview specified config file over env vars or admin UI + +## Acceptance Criteria + +### Functional Scenarios + +**Scenario 1: Load webhooks from config file** +- **Given** a config file exists with webhook URLs +- **When** the application starts +- **Then** webhook URLs are loaded from the config file +- **And** webhooks fire to all configured URLs on submission + +**Scenario 2: Multiple webhooks supported** +- **Given** multiple webhook URLs are in the config file +- **When** a survey is submitted +- **Then** all configured webhooks receive the notification + +**Scenario 3: No config file** +- **Given** no webhook config file exists +- **When** the application starts +- **Then** the application starts normally +- **And** no webhooks fire on submission (graceful degradation) + +### Non-Functional Requirements +- [ ] Configuration: Standard JSON or YAML format +- [ ] Reliability: Invalid config doesn't crash the app + +### Quality Checklist +- [ ] Config file format is documented +- [ ] Multiple URLs work simultaneously +- [ ] Missing config handled gracefully + +## Open Questions +- JSON vs YAML preference? +- Config file location (e.g., `config/webhooks.json`) + +## Dependencies +- STORY-052: Webhook Dispatch on Submission + +## Estimate +**Size**: XS +**Confidence**: High + +**Reasoning**: Simple file loading and parsing. NestJS ConfigModule patterns available. + +## Metadata +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Last Updated**: 2025-12-06 +**Build Date**: diff --git a/product/iterations/2025-12-06-webhooks/stories/story-055-webhook-error-logging.md b/product/iterations/2025-12-06-webhooks/stories/story-055-webhook-error-logging.md new file mode 100644 index 0000000..db0e62b --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/story-055-webhook-error-logging.md @@ -0,0 +1,63 @@ +# User Story: Webhook Error Logging + +**Story ID**: STORY-055 +**Iteration**: 2025-12-06-webhooks +**Priority**: Should have +**Status**: Draft +**Labels**: 2025-12-06-webhooks, conference-organizer, backend, integrations, llm-dev + +## User Story +As a conference organizer, +I want webhook failures logged to the application logging framework, +So that I have visibility into delivery issues without affecting survey submissions. + +## Context +Fire-and-forget means failures are silent by default. This story adds logging so operators can diagnose webhook issues. Uses existing NestJS logging framework. + +## Source +**Discovery Cycle**: 2025-12-06-webhooks +**Synthesis Reference**: `product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md` +**User Need**: "Log errors to the logging framework used by the site" +**Supporting Evidence**: Interview specified error logging for MVP + +## Acceptance Criteria + +### Functional Scenarios + +**Scenario 1: Failed webhook is logged** +- **Given** a webhook URL is configured +- **When** the webhook call fails (timeout, connection error, non-2xx) +- **Then** the failure is logged with: URL, error type, timestamp +- **And** the log level is appropriate (warn or error) + +**Scenario 2: Successful webhook is logged** +- **Given** a webhook URL is configured +- **When** the webhook call succeeds +- **Then** success is logged (info level) with URL and response status + +### Non-Functional Requirements +- [ ] Logging: Uses existing NestJS Logger +- [ ] Observability: Logs include enough context for debugging + +### Quality Checklist +- [ ] Failures appear in application logs +- [ ] Success also logged for complete audit trail +- [ ] No sensitive data in logs + +## Open Questions +- Log full response body on error, or just status? + +## Dependencies +- STORY-052: Webhook Dispatch on Submission + +## Estimate +**Size**: XS +**Confidence**: High + +**Reasoning**: Adding logging statements to existing code. NestJS Logger already available. + +## Metadata +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Last Updated**: 2025-12-06 +**Build Date**: diff --git a/product/iterations/2025-12-06-webhooks/stories/story-056-slack-payload-formatting.md b/product/iterations/2025-12-06-webhooks/stories/story-056-slack-payload-formatting.md new file mode 100644 index 0000000..366edca --- /dev/null +++ b/product/iterations/2025-12-06-webhooks/stories/story-056-slack-payload-formatting.md @@ -0,0 +1,64 @@ +# User Story: Slack Payload Formatting + +**Story ID**: STORY-056 +**Iteration**: 2025-12-06-webhooks +**Priority**: Could have +**Status**: Draft +**Labels**: 2025-12-06-webhooks, conference-organizer, backend, integrations, slack, llm-dev + +## User Story +As a conference organizer, +I want Slack webhooks to receive properly formatted messages, +So that notifications appear as readable Slack messages rather than raw JSON. + +## Context +Slack incoming webhooks expect a specific payload format. This story adds Slack-specific formatting when webhook type is "slack" in config. + +## Source +**Discovery Cycle**: 2025-12-06-webhooks +**Synthesis Reference**: `product/iterations/2025-12-06-webhooks/discovery/synthesis/synthesis-2025-12-06.md` +**User Need**: Slack channel notifications for real-time team visibility +**Supporting Evidence**: Interview identified Slack as primary use case + +## Acceptance Criteria + +### Functional Scenarios + +**Scenario 1: Slack-formatted message** +- **Given** a webhook is configured with type "slack" +- **When** a survey is submitted +- **Then** the payload uses Slack's expected format +- **And** the message renders as readable text in Slack + +**Scenario 2: Message includes link** +- **Given** a Slack webhook fires +- **When** the message appears in Slack +- **Then** the admin dashboard link is clickable + +### Non-Functional Requirements +- [ ] Compatibility: Works with Slack incoming webhooks API +- [ ] Usability: Message readable at a glance + +### Quality Checklist +- [ ] Message renders correctly in Slack +- [ ] Link is clickable +- [ ] Non-Slack webhooks unaffected + +## Open Questions +- Simple text vs Slack blocks format? + +## Dependencies +- STORY-052: Webhook Dispatch on Submission +- STORY-054: Webhook Config File (for webhook type detection) + +## Estimate +**Size**: XS +**Confidence**: High + +**Reasoning**: Conditional payload formatting based on webhook type. Slack format is well-documented. + +## Metadata +**Iteration**: 2025-12-06-webhooks +**Created**: 2025-12-06 +**Last Updated**: 2025-12-06 +**Build Date**: diff --git a/product/metrics/timing-log.jsonl b/product/metrics/timing-log.jsonl index 26ab336..9076ac5 100644 --- a/product/metrics/timing-log.jsonl +++ b/product/metrics/timing-log.jsonl @@ -10,3 +10,7 @@ {"timestamp": "2025-12-02T21:20:30Z", "command": "/req", "iteration": "2025-12-02-admin-page", "start": "2025-12-02T21:16:00Z", "end": "2025-12-02T21:20:30Z", "duration_seconds": 270, "status": "success", "metadata": {"stories_created": 2, "story_ids": ["STORY-045", "STORY-046"], "template": "llm-dev", "granularity": "coarse"}} {"timestamp": "2025-12-02T17:47:30Z", "command": "/iter", "iteration": "2025-12-02-dark-mode", "start": "2025-12-02T17:45:00Z", "end": "2025-12-02T17:47:30Z", "duration_seconds": 150, "status": "success", "metadata": {"interview_conducted": false}} {"timestamp": "2025-12-06T16:48:30Z", "command": "/rel", "iteration": null, "release": "002", "start": "2025-12-06T16:45:00Z", "end": "2025-12-06T16:48:30Z", "duration_seconds": 210, "status": "success", "metadata": {"stories_built": 1, "capabilities_without_stories": 3}} +{"timestamp": "2025-12-06T14:55:00Z", "command": "/iter", "iteration": "2025-12-06-sandbox-js1", "start": "2025-12-06T14:44:00Z", "end": "2025-12-06T14:55:00Z", "duration_seconds": 660, "status": "success", "metadata": {"interview_conducted": true}} +{"timestamp": "2025-12-06T15:10:00Z", "command": "/iter", "iteration": "2025-12-06-webhooks", "start": "2025-12-06T15:05:00Z", "end": "2025-12-06T15:10:00Z", "duration_seconds": 300, "status": "success", "metadata": {"interview_conducted": true}} +{"timestamp": "2025-12-06T15:15:00Z", "command": "/synth", "iteration": "2025-12-06-webhooks", "start": "2025-12-06T15:10:00Z", "end": "2025-12-06T15:15:00Z", "duration_seconds": 300, "status": "success", "metadata": {"themes_identified": 4, "pain_points": 3}} +{"timestamp": "2025-12-06T15:20:00Z", "command": "/req", "iteration": "2025-12-06-webhooks", "start": "2025-12-06T15:15:00Z", "end": "2025-12-06T15:20:00Z", "duration_seconds": 300, "status": "success", "metadata": {"stories_created": 2, "story_ids": ["STORY-052", "STORY-053"], "template": "LLM Developer", "granularity": "standard"}}