feat(gateway): WorkspaceService + ProjectBootstrapService + TeamsService (P8-015)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

- WorkspaceService: path resolution, git init/clone, directory lifecycle (create/delete/exists), user and team root provisioning
- ProjectBootstrapService: orchestrates DB record creation (via Brain) + workspace directory init in a single call
- TeamsService: isMember, canAccessProject, findAll, findById, listMembers via Drizzle DB queries
- WorkspaceController: POST /api/workspaces — auth-guarded project bootstrap endpoint
- TeamsController: GET /api/teams, /:teamId, /:teamId/members, /:teamId/members/:userId
- WorkspaceModule wired into AppModule
- workspace.service.spec.ts: 5 unit tests for resolvePath (user, team, fallback, env var, default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:06:01 -05:00
parent 24f5c0699a
commit 0821393c1d
8 changed files with 401 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common';
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export interface WorkspaceProject {
id: string;
ownerType: 'user' | 'team';
userId: string;
teamId: string | null;
}
@Injectable()
export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
private readonly mosaicRoot: string;
constructor() {
this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
}
/**
* Resolve the workspace path for a project.
* Solo: $MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>/
* Team: $MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>/
*/
resolvePath(project: WorkspaceProject): string {
if (project.ownerType === 'team' && project.teamId) {
return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id);
}
return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id);
}
/**
* Create a workspace directory and initialize it as a git repo.
* If repoUrl is provided, clone instead of init.
*/
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
const workspacePath = this.resolvePath(project);
// Create directory
await fs.mkdir(workspacePath, { recursive: true });
if (repoUrl) {
// Clone existing repo
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
} else {
// Init new git repo
await execFileAsync('git', ['init'], { cwd: workspacePath });
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], {
cwd: workspacePath,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Mosaic',
GIT_AUTHOR_EMAIL: 'mosaic@localhost',
GIT_COMMITTER_NAME: 'Mosaic',
GIT_COMMITTER_EMAIL: 'mosaic@localhost',
},
});
this.logger.log(`Initialized git workspace at ${workspacePath}`);
}
return workspacePath;
}
/**
* Delete a workspace directory recursively.
*/
async delete(project: WorkspaceProject): Promise<void> {
const workspacePath = this.resolvePath(project);
try {
await fs.rm(workspacePath, { recursive: true, force: true });
this.logger.log(`Deleted workspace at ${workspacePath}`);
} catch (err) {
this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`);
}
}
/**
* Check whether the workspace directory exists.
*/
async exists(project: WorkspaceProject): Promise<boolean> {
const workspacePath = this.resolvePath(project);
try {
await fs.access(workspacePath);
return true;
} catch {
return false;
}
}
/**
* Create the base user workspace directory (call on user registration).
*/
async createUserRoot(userId: string): Promise<void> {
const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId);
await fs.mkdir(userRoot, { recursive: true });
}
/**
* Create the base team workspace directory (call on team creation).
*/
async createTeamRoot(teamId: string): Promise<void> {
const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId);
await fs.mkdir(teamRoot, { recursive: true });
}
}