@assistkick/create 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Service initialization — creates pipeline, claude service, and related instances.
3
+ * Deferred until VERBOSE flag is set at startup.
4
+ */
5
+
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { existsSync } from 'node:fs';
9
+ import { createClaudeService } from '@interview-system/shared/lib/claude-service.js';
10
+ import { Pipeline } from '@interview-system/shared/lib/pipeline.js';
11
+ import { PipelineStateStore } from '@interview-system/shared/lib/pipeline-state-store.js';
12
+ import { PromptBuilder } from '@interview-system/shared/lib/prompt_builder.js';
13
+ import { GitWorkflow } from '@interview-system/shared/lib/git_workflow.js';
14
+ import { getDb } from '@interview-system/shared/lib/db.js';
15
+ import { getKanbanEntry, saveKanbanEntry } from '@interview-system/shared/lib/kanban.js';
16
+ import { getNode } from '@interview-system/shared/lib/graph.js';
17
+ import { WorkSummaryParser } from '@interview-system/shared/lib/work_summary_parser.js';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ // Navigate from packages/backend/src/services/ up to product-system/
21
+ const SKILL_ROOT = join(__dirname, '..', '..', '..', '..');
22
+ const SHARED_DIR = join(SKILL_ROOT, 'packages', 'shared');
23
+
24
+ const IS_DEV = import.meta.url.endsWith('.ts');
25
+ const TOOL_EXT = IS_DEV ? '.ts' : '.js';
26
+ const TOOL_RUNNER = IS_DEV ? 'tsx' : 'node';
27
+ const TOOLS_DIR = IS_DEV ? join(SHARED_DIR, 'tools') : join(__dirname, '..', '..', '..', 'shared', 'build', 'tools');
28
+ const DATA_DIR = SHARED_DIR;
29
+ const PROJECT_ROOT = join(SKILL_ROOT, '..');
30
+ const SKILLS_DIR = join(PROJECT_ROOT, '.claude', 'skills');
31
+ const WORKTREES_DIR = join(PROJECT_ROOT, '.worktrees');
32
+ const DEVELOPER_SKILL_PATH = join(SKILLS_DIR, 'product-developer', 'SKILL.md');
33
+ const REVIEWER_SKILL_PATH = join(SKILLS_DIR, 'product-code-reviewer', 'SKILL.md');
34
+ const DEBUGGER_SKILL_PATH = join(SKILLS_DIR, 'product-debugger', 'SKILL.md');
35
+
36
+ export const log = (tag: string, ...args: any[]) => {
37
+ const ts = new Date().toISOString().slice(11, 23);
38
+ console.log(`[${ts}] [${tag}]`, ...args);
39
+ };
40
+
41
+ export let claudeService: any;
42
+ export let pipeline: any;
43
+ export let pipelineStateStore: any;
44
+
45
+ export const paths = {
46
+ projectRoot: PROJECT_ROOT,
47
+ worktreesDir: WORKTREES_DIR,
48
+ skillsDir: SKILLS_DIR,
49
+ toolsDir: TOOLS_DIR,
50
+ dataDir: DATA_DIR,
51
+ developerSkillPath: DEVELOPER_SKILL_PATH,
52
+ reviewerSkillPath: REVIEWER_SKILL_PATH,
53
+ debuggerSkillPath: DEBUGGER_SKILL_PATH,
54
+ toolExt: TOOL_EXT,
55
+ toolRunner: TOOL_RUNNER,
56
+ };
57
+
58
+ export const initServices = (verbose: boolean) => {
59
+ claudeService = createClaudeService({ verbose, log });
60
+ pipelineStateStore = new PipelineStateStore({ getDb });
61
+ const promptBuilder = new PromptBuilder({ paths, log });
62
+ const gitWorkflow = new GitWorkflow({ claudeService, projectRoot: PROJECT_ROOT, worktreesDir: WORKTREES_DIR, log });
63
+ const workSummaryParser = new WorkSummaryParser();
64
+ pipeline = new Pipeline({
65
+ promptBuilder,
66
+ gitWorkflow,
67
+ claudeService,
68
+ kanban: { getKanbanEntry, saveKanbanEntry },
69
+ paths,
70
+ log,
71
+ stateStore: pipelineStateStore,
72
+ workSummaryParser,
73
+ getNode,
74
+ });
75
+
76
+ // Mark any active pipeline states as interrupted (server restart recovery)
77
+ pipelineStateStore.markInterrupted().catch((err: any) => {
78
+ log('STARTUP', `Failed to mark interrupted pipelines: ${err.message}`);
79
+ });
80
+ };
@@ -0,0 +1,235 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { InvitationService } from './invitation_service.ts';
4
+
5
+ const createMockDb = () => {
6
+ const state = {
7
+ insertedInvitations: [] as any[],
8
+ insertedUsers: [] as any[],
9
+ updatedInvitations: [] as any[],
10
+ };
11
+
12
+ let selectResults: any[][] = [];
13
+ let selectCallIndex = 0;
14
+
15
+ const db = {
16
+ select: mock.fn(() => ({
17
+ from: mock.fn(() => ({
18
+ where: mock.fn(() => {
19
+ const result = selectResults[selectCallIndex] || [];
20
+ selectCallIndex++;
21
+ return result;
22
+ }),
23
+ })),
24
+ })),
25
+ insert: mock.fn((table: any) => ({
26
+ values: mock.fn((vals: any) => {
27
+ if (vals.tokenHash) state.insertedInvitations.push(vals);
28
+ if (vals.passwordHash) state.insertedUsers.push(vals);
29
+ }),
30
+ })),
31
+ update: mock.fn(() => ({
32
+ set: mock.fn((vals: any) => ({
33
+ where: mock.fn(() => {
34
+ if (vals.acceptedAt) state.updatedInvitations.push(vals);
35
+ }),
36
+ })),
37
+ })),
38
+ _state: state,
39
+ _setSelectResults: (results: any[][]) => {
40
+ selectResults = results;
41
+ selectCallIndex = 0;
42
+ },
43
+ };
44
+
45
+ return db;
46
+ };
47
+
48
+ const createMockEmailService = () => ({
49
+ sendInvitationEmail: mock.fn(async () => {}),
50
+ sendPasswordResetEmail: mock.fn(async () => {}),
51
+ });
52
+
53
+ const createMockAuthService = () => ({
54
+ hashPassword: mock.fn(async (pw: string) => `hashed_${pw}`),
55
+ verifyPassword: mock.fn(async () => false),
56
+ generateAccessToken: mock.fn(async () => 'token'),
57
+ generateRefreshToken: mock.fn(async () => 'refresh'),
58
+ verifyToken: mock.fn(async () => ({ sub: '1' })),
59
+ setAuthCookies: mock.fn(),
60
+ clearAuthCookies: mock.fn(),
61
+ });
62
+
63
+ const noop = () => {};
64
+
65
+ describe('InvitationService', () => {
66
+ describe('sendInvitation', () => {
67
+ it('sends invitation email for new user', async () => {
68
+ const mockDb = createMockDb();
69
+ const emailService = createMockEmailService();
70
+ const authService = createMockAuthService();
71
+
72
+ // First select: no existing user; Second select: no existing invitations
73
+ mockDb._setSelectResults([[], []]);
74
+
75
+ const service = new InvitationService({
76
+ getDb: () => mockDb,
77
+ emailService: emailService as any,
78
+ authService: authService as any,
79
+ appBaseUrl: 'http://localhost:3000',
80
+ log: noop,
81
+ });
82
+
83
+ await service.sendInvitation('new@example.com', 'admin-1');
84
+
85
+ assert.equal(emailService.sendInvitationEmail.mock.calls.length, 1);
86
+ assert.equal(mockDb.insert.mock.calls.length, 1);
87
+ const emailCall = emailService.sendInvitationEmail.mock.calls[0];
88
+ assert.equal(emailCall.arguments[0], 'new@example.com');
89
+ assert.ok(emailCall.arguments[1].includes('/accept-invitation?token='));
90
+ assert.ok(emailCall.arguments[1].includes('email=new%40example.com'));
91
+ });
92
+
93
+ it('throws when user already exists', async () => {
94
+ const mockDb = createMockDb();
95
+ const emailService = createMockEmailService();
96
+ const authService = createMockAuthService();
97
+
98
+ // First select: existing user found
99
+ mockDb._setSelectResults([[{ id: 'user-1' }]]);
100
+
101
+ const service = new InvitationService({
102
+ getDb: () => mockDb,
103
+ emailService: emailService as any,
104
+ authService: authService as any,
105
+ appBaseUrl: 'http://localhost:3000',
106
+ log: noop,
107
+ });
108
+
109
+ await assert.rejects(
110
+ () => service.sendInvitation('existing@example.com', 'admin-1'),
111
+ { message: 'A user with this email already exists' },
112
+ );
113
+
114
+ assert.equal(emailService.sendInvitationEmail.mock.calls.length, 0);
115
+ });
116
+
117
+ it('throws when active invitation exists', async () => {
118
+ const mockDb = createMockDb();
119
+ const emailService = createMockEmailService();
120
+ const authService = createMockAuthService();
121
+
122
+ const futureDate = new Date(Date.now() + 86400000).toISOString();
123
+ // First select: no user; Second select: active invitation
124
+ mockDb._setSelectResults([
125
+ [],
126
+ [{ id: 'inv-1', expiresAt: futureDate, acceptedAt: null }],
127
+ ]);
128
+
129
+ const service = new InvitationService({
130
+ getDb: () => mockDb,
131
+ emailService: emailService as any,
132
+ authService: authService as any,
133
+ appBaseUrl: 'http://localhost:3000',
134
+ log: noop,
135
+ });
136
+
137
+ await assert.rejects(
138
+ () => service.sendInvitation('pending@example.com', 'admin-1'),
139
+ { message: 'An active invitation already exists for this email' },
140
+ );
141
+ });
142
+
143
+ it('throws when email service fails', async () => {
144
+ const mockDb = createMockDb();
145
+ const emailService = createMockEmailService();
146
+ const authService = createMockAuthService();
147
+
148
+ emailService.sendInvitationEmail = mock.fn(async () => {
149
+ throw new Error('SMTP failed');
150
+ });
151
+
152
+ mockDb._setSelectResults([[], []]);
153
+
154
+ const service = new InvitationService({
155
+ getDb: () => mockDb,
156
+ emailService: emailService as any,
157
+ authService: authService as any,
158
+ appBaseUrl: 'http://localhost:3000',
159
+ log: noop,
160
+ });
161
+
162
+ await assert.rejects(
163
+ () => service.sendInvitation('new@example.com', 'admin-1'),
164
+ { message: 'Failed to send invitation email' },
165
+ );
166
+ });
167
+ });
168
+
169
+ describe('validateToken', () => {
170
+ it('returns valid:false when no matching invitation', async () => {
171
+ const mockDb = createMockDb();
172
+ const emailService = createMockEmailService();
173
+ const authService = createMockAuthService();
174
+
175
+ mockDb._setSelectResults([[]]);
176
+
177
+ const service = new InvitationService({
178
+ getDb: () => mockDb,
179
+ emailService: emailService as any,
180
+ authService: authService as any,
181
+ appBaseUrl: 'http://localhost:3000',
182
+ log: noop,
183
+ });
184
+
185
+ const result = await service.validateToken('bad-token', 'test@example.com');
186
+ assert.deepEqual(result, { valid: false });
187
+ });
188
+ });
189
+
190
+ describe('acceptInvitation', () => {
191
+ it('throws when user already exists', async () => {
192
+ const mockDb = createMockDb();
193
+ const emailService = createMockEmailService();
194
+ const authService = createMockAuthService();
195
+
196
+ // First select: user exists
197
+ mockDb._setSelectResults([[{ id: 'user-1' }]]);
198
+
199
+ const service = new InvitationService({
200
+ getDb: () => mockDb,
201
+ emailService: emailService as any,
202
+ authService: authService as any,
203
+ appBaseUrl: 'http://localhost:3000',
204
+ log: noop,
205
+ });
206
+
207
+ await assert.rejects(
208
+ () => service.acceptInvitation('token', 'existing@example.com', 'password123'),
209
+ { message: 'A user with this email already exists' },
210
+ );
211
+ });
212
+
213
+ it('throws when no matching invitation token', async () => {
214
+ const mockDb = createMockDb();
215
+ const emailService = createMockEmailService();
216
+ const authService = createMockAuthService();
217
+
218
+ // First select: no user; Second select: no invitations
219
+ mockDb._setSelectResults([[], []]);
220
+
221
+ const service = new InvitationService({
222
+ getDb: () => mockDb,
223
+ emailService: emailService as any,
224
+ authService: authService as any,
225
+ appBaseUrl: 'http://localhost:3000',
226
+ log: noop,
227
+ });
228
+
229
+ await assert.rejects(
230
+ () => service.acceptInvitation('bad-token', 'new@example.com', 'password123'),
231
+ { message: 'Invalid or expired invitation token' },
232
+ );
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Invitation service — handles inviting new users by email.
3
+ * Only admins can send invitations. Invited users click a link and set a password.
4
+ */
5
+
6
+ import { randomBytes, randomUUID } from 'node:crypto';
7
+ import { hash, compare } from 'bcryptjs';
8
+ import { eq } from 'drizzle-orm';
9
+ import { users, invitations } from '@interview-system/shared/db/schema.js';
10
+ import type { EmailService } from './email_service.js';
11
+ import type { AuthService } from './auth_service.js';
12
+
13
+ const TOKEN_EXPIRY_MS = 48 * 60 * 60 * 1000; // 48 hours
14
+ const SALT_ROUNDS = 12;
15
+
16
+ interface InvitationServiceDeps {
17
+ getDb: () => any;
18
+ emailService: EmailService;
19
+ authService: AuthService;
20
+ appBaseUrl: string;
21
+ log: (tag: string, ...args: any[]) => void;
22
+ }
23
+
24
+ export class InvitationService {
25
+ private readonly getDb: () => any;
26
+ private readonly emailService: EmailService;
27
+ private readonly authService: AuthService;
28
+ private readonly appBaseUrl: string;
29
+ private readonly log: (tag: string, ...args: any[]) => void;
30
+
31
+ constructor({ getDb, emailService, authService, appBaseUrl, log }: InvitationServiceDeps) {
32
+ this.getDb = getDb;
33
+ this.emailService = emailService;
34
+ this.authService = authService;
35
+ this.appBaseUrl = appBaseUrl;
36
+ this.log = log;
37
+ }
38
+
39
+ /**
40
+ * Send an invitation email. Only callable by admins.
41
+ * Throws if the email is already registered or has a pending invitation.
42
+ */
43
+ sendInvitation = async (email: string, invitedByUserId: string): Promise<void> => {
44
+ const db = this.getDb();
45
+ const normalizedEmail = email.toLowerCase();
46
+
47
+ // Check if user already exists
48
+ const [existingUser] = await db
49
+ .select({ id: users.id })
50
+ .from(users)
51
+ .where(eq(users.email, normalizedEmail));
52
+
53
+ if (existingUser) {
54
+ throw new Error('A user with this email already exists');
55
+ }
56
+
57
+ // Check for pending (non-expired, non-accepted) invitation
58
+ const existingInvitations = await db
59
+ .select({ id: invitations.id, expiresAt: invitations.expiresAt, acceptedAt: invitations.acceptedAt })
60
+ .from(invitations)
61
+ .where(eq(invitations.email, normalizedEmail));
62
+
63
+ const hasPending = existingInvitations.some(
64
+ (inv: any) => !inv.acceptedAt && new Date(inv.expiresAt) > new Date(),
65
+ );
66
+
67
+ if (hasPending) {
68
+ throw new Error('An active invitation already exists for this email');
69
+ }
70
+
71
+ // Generate secure token
72
+ const rawToken = randomBytes(32).toString('hex');
73
+ const tokenHash = await hash(rawToken, SALT_ROUNDS);
74
+ const now = new Date().toISOString();
75
+ const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
76
+
77
+ await db.insert(invitations).values({
78
+ id: randomUUID(),
79
+ email: normalizedEmail,
80
+ tokenHash,
81
+ invitedBy: invitedByUserId,
82
+ expiresAt,
83
+ createdAt: now,
84
+ });
85
+
86
+ const invitationUrl = `${this.appBaseUrl}/accept-invitation?token=${rawToken}&email=${encodeURIComponent(normalizedEmail)}`;
87
+
88
+ try {
89
+ await this.emailService.sendInvitationEmail(normalizedEmail, invitationUrl);
90
+ this.log('INVITE', `Invitation sent to ${normalizedEmail}`);
91
+ } catch (err: any) {
92
+ this.log('INVITE', `Failed to send invitation email: ${err.message}`);
93
+ throw new Error('Failed to send invitation email');
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Validate an invitation token. Returns the invitation email if valid.
99
+ */
100
+ validateToken = async (token: string, email: string): Promise<{ valid: boolean }> => {
101
+ const db = this.getDb();
102
+ const normalizedEmail = email.toLowerCase();
103
+
104
+ const candidates = await db
105
+ .select({
106
+ id: invitations.id,
107
+ tokenHash: invitations.tokenHash,
108
+ expiresAt: invitations.expiresAt,
109
+ acceptedAt: invitations.acceptedAt,
110
+ })
111
+ .from(invitations)
112
+ .where(eq(invitations.email, normalizedEmail));
113
+
114
+ for (const candidate of candidates) {
115
+ if (candidate.acceptedAt) continue;
116
+ if (new Date(candidate.expiresAt) < new Date()) continue;
117
+
118
+ const matches = await compare(token, candidate.tokenHash);
119
+ if (matches) return { valid: true };
120
+ }
121
+
122
+ return { valid: false };
123
+ };
124
+
125
+ /**
126
+ * Accept an invitation — validate token, create user, mark invitation as accepted.
127
+ */
128
+ acceptInvitation = async (token: string, email: string, password: string): Promise<{ userId: string; email: string }> => {
129
+ const db = this.getDb();
130
+ const normalizedEmail = email.toLowerCase();
131
+ const now = new Date().toISOString();
132
+
133
+ // Check if user already exists (race condition guard)
134
+ const [existingUser] = await db
135
+ .select({ id: users.id })
136
+ .from(users)
137
+ .where(eq(users.email, normalizedEmail));
138
+
139
+ if (existingUser) {
140
+ throw new Error('A user with this email already exists');
141
+ }
142
+
143
+ // Find matching valid invitation
144
+ const candidates = await db
145
+ .select({
146
+ id: invitations.id,
147
+ tokenHash: invitations.tokenHash,
148
+ expiresAt: invitations.expiresAt,
149
+ acceptedAt: invitations.acceptedAt,
150
+ })
151
+ .from(invitations)
152
+ .where(eq(invitations.email, normalizedEmail));
153
+
154
+ let matchedInvitation: { id: string } | null = null;
155
+ for (const candidate of candidates) {
156
+ if (candidate.acceptedAt) continue;
157
+ if (new Date(candidate.expiresAt) < new Date()) continue;
158
+
159
+ const matches = await compare(token, candidate.tokenHash);
160
+ if (matches) {
161
+ matchedInvitation = { id: candidate.id };
162
+ break;
163
+ }
164
+ }
165
+
166
+ if (!matchedInvitation) {
167
+ throw new Error('Invalid or expired invitation token');
168
+ }
169
+
170
+ // Mark invitation as accepted
171
+ await db
172
+ .update(invitations)
173
+ .set({ acceptedAt: now })
174
+ .where(eq(invitations.id, matchedInvitation.id));
175
+
176
+ // Create the user
177
+ const userId = randomUUID();
178
+ const passwordHash = await this.authService.hashPassword(password);
179
+
180
+ await db.insert(users).values({
181
+ id: userId,
182
+ email: normalizedEmail,
183
+ passwordHash,
184
+ role: 'user',
185
+ createdAt: now,
186
+ updatedAt: now,
187
+ });
188
+
189
+ this.log('INVITE', `Invitation accepted, user created: ${normalizedEmail}`);
190
+
191
+ return { userId, email: normalizedEmail };
192
+ };
193
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { PasswordResetService } from './password_reset_service.ts';
4
+
5
+ // Mock DB builder — returns a mock Drizzle-like object
6
+ const createMockDb = () => {
7
+ const state = {
8
+ insertedTokens: [] as any[],
9
+ updatedTokens: [] as any[],
10
+ updatedUsers: [] as any[],
11
+ usersData: [] as any[],
12
+ tokensData: [] as any[],
13
+ };
14
+
15
+ const db = {
16
+ select: mock.fn(() => ({
17
+ from: mock.fn(() => ({
18
+ where: mock.fn(() => {
19
+ // Return users or tokens depending on context
20
+ return state._nextResult || [];
21
+ }),
22
+ })),
23
+ })),
24
+ insert: mock.fn(() => ({
25
+ values: mock.fn((vals: any) => {
26
+ state.insertedTokens.push(vals);
27
+ }),
28
+ })),
29
+ update: mock.fn(() => ({
30
+ set: mock.fn((vals: any) => ({
31
+ where: mock.fn(() => {
32
+ if (vals.usedAt) state.updatedTokens.push(vals);
33
+ if (vals.passwordHash) state.updatedUsers.push(vals);
34
+ }),
35
+ })),
36
+ })),
37
+ _state: state,
38
+ _nextResult: [] as any[],
39
+ _setNextResult: (result: any[]) => { db._nextResult = result; },
40
+ };
41
+
42
+ return db;
43
+ };
44
+
45
+ const createMockEmailService = () => ({
46
+ sendPasswordResetEmail: mock.fn(async () => {}),
47
+ });
48
+
49
+ const createMockAuthService = () => ({
50
+ hashPassword: mock.fn(async (pw: string) => `hashed_${pw}`),
51
+ verifyPassword: mock.fn(async () => false),
52
+ generateAccessToken: mock.fn(async () => 'token'),
53
+ generateRefreshToken: mock.fn(async () => 'refresh'),
54
+ verifyToken: mock.fn(async () => ({ sub: '1' })),
55
+ setAuthCookies: mock.fn(),
56
+ clearAuthCookies: mock.fn(),
57
+ });
58
+
59
+ const noop = () => {};
60
+
61
+ describe('PasswordResetService', () => {
62
+ describe('requestReset', () => {
63
+ it('sends email when user exists', async () => {
64
+ const mockDb = createMockDb();
65
+ const emailService = createMockEmailService();
66
+ const authService = createMockAuthService();
67
+
68
+ // First call returns user, second returns tokens
69
+ let callCount = 0;
70
+ mockDb.select = mock.fn(() => ({
71
+ from: mock.fn(() => ({
72
+ where: mock.fn(() => {
73
+ callCount++;
74
+ if (callCount === 1) return [{ id: 'user-1', email: 'test@example.com' }];
75
+ return [];
76
+ }),
77
+ })),
78
+ }));
79
+
80
+ const service = new PasswordResetService({
81
+ getDb: () => mockDb,
82
+ emailService: emailService as any,
83
+ authService: authService as any,
84
+ appBaseUrl: 'http://localhost:3000',
85
+ log: noop,
86
+ });
87
+
88
+ await service.requestReset('test@example.com');
89
+
90
+ assert.equal(emailService.sendPasswordResetEmail.mock.calls.length, 1);
91
+ assert.equal(mockDb.insert.mock.calls.length, 1);
92
+ const emailCall = emailService.sendPasswordResetEmail.mock.calls[0];
93
+ assert.equal(emailCall.arguments[0], 'test@example.com');
94
+ assert.ok(emailCall.arguments[1].startsWith('http://localhost:3000/reset-password?token='));
95
+ });
96
+
97
+ it('silently succeeds when user does not exist', async () => {
98
+ const mockDb = createMockDb();
99
+ const emailService = createMockEmailService();
100
+ const authService = createMockAuthService();
101
+
102
+ mockDb.select = mock.fn(() => ({
103
+ from: mock.fn(() => ({
104
+ where: mock.fn(() => []),
105
+ })),
106
+ }));
107
+
108
+ const service = new PasswordResetService({
109
+ getDb: () => mockDb,
110
+ emailService: emailService as any,
111
+ authService: authService as any,
112
+ appBaseUrl: 'http://localhost:3000',
113
+ log: noop,
114
+ });
115
+
116
+ await service.requestReset('nonexistent@example.com');
117
+
118
+ assert.equal(emailService.sendPasswordResetEmail.mock.calls.length, 0);
119
+ assert.equal(mockDb.insert.mock.calls.length, 0);
120
+ });
121
+
122
+ it('throws when email service fails', async () => {
123
+ const mockDb = createMockDb();
124
+ const emailService = createMockEmailService();
125
+ const authService = createMockAuthService();
126
+
127
+ emailService.sendPasswordResetEmail = mock.fn(async () => {
128
+ throw new Error('SMTP failed');
129
+ });
130
+
131
+ mockDb.select = mock.fn(() => ({
132
+ from: mock.fn(() => ({
133
+ where: mock.fn(() => [{ id: 'user-1', email: 'test@example.com' }]),
134
+ })),
135
+ }));
136
+
137
+ const service = new PasswordResetService({
138
+ getDb: () => mockDb,
139
+ emailService: emailService as any,
140
+ authService: authService as any,
141
+ appBaseUrl: 'http://localhost:3000',
142
+ log: noop,
143
+ });
144
+
145
+ await assert.rejects(
146
+ () => service.requestReset('test@example.com'),
147
+ { message: 'Failed to send reset email' },
148
+ );
149
+ });
150
+ });
151
+ });