godcrm/backend/routes/v3/spaces/crud.js
GOD CRM Release 065f2edd23
Some checks are pending
CI / Lint / Typecheck / Test / Build (push) Waiting to run
CI / PostgreSQL Integration Tests (push) Waiting to run
GOD CRM — public scrubbed snapshot (2026-08-30)
Refresh of the open-core distribution from the private tree.

Included since the previous snapshot:
- Mail module (ADR-158/159/160/169): composer, labels, scheduling,
  attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083
- Crawler-readable SSR for /blog and public spaces (ADR-190):
  blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages
- Registration policy + referral/promo settings (ADR-183/188)
- Message translation + language detection (ADR-185)
- Reddit connector for the agent-tool surface

Excised from the public distribution (unchanged policy): infrastructure
topology and host config, internal ops scripts, DB cleanup snapshots,
business documents, throwaway debug scripts, and two private product
lines (SC-SIM simulator, personal one-off tools). Real host addresses
are replaced with placeholders; credential-shaped literals are redacted.

Frontend build verified green on this tree.
2026-08-30 15:13:28 +03:00

464 lines
14 KiB
JavaScript

/**
* Spaces CRUD Routes
* GET /, POST /, GET /:id, PUT /:id, PATCH /:id, DELETE /:id
*/
import { createSpace, getSpacesByUser, getSpaceById, updateSpace, deleteSpace, checkUserSpaceAccess } from '../../../services/SpaceService.js';
import { getProjectsBySpace, getProjectsBySpaceForUser } from '../../../services/ProjectService.js';
import { getSpaceDashboard } from '../../../services/DashboardService.js';
import { createAIAgentsPack } from '../../../services/AIAgentsPackService.js';
import { createBusinessPack } from '../../../services/BusinessPackService.js';
import { installKanbanPack } from '../../../services/KanbanPackService.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, created, error, badRequest, notFound } from '../../../utils/response.js';
/**
* @swagger
* /spaces:
* get:
* tags: [Spaces]
* summary: Get all spaces for authenticated user
* description: Returns all spaces the user has access to (owned or member of)
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of spaces
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* $ref: '#/components/schemas/Space'
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
/**
* @swagger
* /spaces:
* post:
* tags: [Spaces]
* summary: Create new space
* description: Creates a new workspace. For 'ai' type, also creates AI Agents pack. For 'business' type, creates Business Pack.
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - type
* properties:
* name:
* type: string
* example: "My Workspace"
* description:
* type: string
* icon:
* type: string
* type:
* type: string
* enum: [personal, business, ai, custom]
* theme_primary:
* type: string
* theme_secondary:
* type: string
* theme_tertiary:
* type: string
* settings:
* type: object
* responses:
* 201:
* description: Space created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* space:
* $ref: '#/components/schemas/Space'
* default_dashboard:
* type: object
* ai_agents_pack:
* type: object
* nullable: true
* business_pack:
* type: object
* nullable: true
* 400:
* description: Validation error
* 401:
* description: Unauthorized
*/
/**
* @swagger
* /spaces/{id}:
* get:
* tags: [Spaces]
* summary: Get space by ID
* description: Returns space with its projects and dashboard. Checks user access permissions.
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Space ID
* responses:
* 200:
* description: Space with projects and dashboard
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* space:
* $ref: '#/components/schemas/Space'
* projects:
* type: array
* items:
* type: object
* dashboard:
* type: object
* 403:
* description: Forbidden - no access to this space
* 404:
* description: Space not found
*/
/**
* @swagger
* /spaces/{id}:
* put:
* tags: [Spaces]
* summary: Update space
* description: Updates space properties. Only owner or admin can update. Cannot change space type.
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Space ID
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description:
* type: string
* icon:
* type: string
* theme_primary:
* type: string
* theme_secondary:
* type: string
* theme_tertiary:
* type: string
* settings:
* type: object
* access_control:
* type: string
* enum: [roles, members]
* responses:
* 200:
* description: Space updated
* 400:
* description: Validation error
* 403:
* description: Forbidden
* 404:
* description: Space not found
*/
/**
* @swagger
* /spaces/{id}:
* delete:
* tags: [Spaces]
* summary: Delete space
* description: Deletes space with CASCADE delete of all projects, tables, and dashboards. Only owner or admin can delete.
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Space ID
* responses:
* 200:
* description: Space deleted
* 403:
* description: Forbidden - only owner or admin can delete
* 404:
* description: Space not found
*/
export default function registerCrudRoutes(router) {
router.get('/', async (req, res) => {
try {
const spaces = await getSpacesByUser(req.user.id, req.user.role);
success(res, spaces);
} catch (err) {
apiLogger.error('Error fetching spaces:', err);
error(res, 'FETCH_ERROR', err.message, 500);
}
});
router.post('/', async (req, res) => {
try {
const { name, description, icon, type, theme_primary, theme_secondary, theme_tertiary, settings } = req.body;
// Validation
if (!name) {
return badRequest(res, 'name is required', 'VALIDATION_ERROR');
}
if (!type) {
return badRequest(res, 'type is required', 'VALIDATION_ERROR');
}
// ADR-183 Block A: community viewers may not create spaces via the user route.
// This gates the *user-initiated* path ONLY — the system bootstrap path
// (registration → autoCreateDefaultProjects → createSpace service) does not go
// through this route, so a new community user's own default space still provisions.
if (req.user.role === 'viewer') {
return error(res, 'FORBIDDEN', 'Viewers cannot create spaces', 403);
}
const space = await createSpace({
owner_id: req.user.id,
name,
description,
icon,
type,
theme_primary,
theme_secondary,
theme_tertiary,
settings
});
// If AI type, create AI Agents pack
let aiAgentsPack = null;
if (type === 'ai') {
try {
aiAgentsPack = await createAIAgentsPack(space.id, req.user.id);
apiLogger.info(`AI Agents pack created for space ${space.id}:`, aiAgentsPack);
} catch (packError) {
apiLogger.error('Error creating AI Agents pack:', packError);
// Don't fail space creation if pack fails
}
}
// If Business type, create Business Pack
let businessPack = null;
if (type === 'business') {
try {
businessPack = await createBusinessPack(space.id, req.user.id);
apiLogger.info(`Business pack created for space ${space.id}:`, businessPack);
} catch (packError) {
apiLogger.error('Error creating Business pack:', packError);
// Don't fail space creation if pack fails
}
}
// If Kanban type, create Kanban Pack
let kanbanPack = null;
if (type === 'kanban') {
try {
kanbanPack = await installKanbanPack(space.id, req.user.id);
apiLogger.info(`Kanban pack created for space ${space.id}:`, kanbanPack);
} catch (packError) {
apiLogger.error('Error creating Kanban pack:', packError);
// Don't fail space creation if pack fails
}
}
// Get default dashboard
const dashboard = await getSpaceDashboard(space.id);
created(res, {
space,
default_dashboard: dashboard,
ai_agents_pack: aiAgentsPack,
business_pack: businessPack,
kanban_pack: kanbanPack
});
} catch (err) {
apiLogger.error('Error creating space:', err);
if (err.message.includes('Invalid space type')) {
return badRequest(res, err.message, 'VALIDATION_ERROR');
}
error(res, 'CREATE_ERROR', err.message, 500);
}
});
router.get('/:id', async (req, res) => {
try {
const spaceId = parseInt(req.params.id);
const space = await getSpaceById(spaceId);
if (!space) {
return notFound(res, 'Space');
}
// Check access using SpaceService (supports roles mode and members mode)
const hasAccess = await checkUserSpaceAccess(req.user.id, req.user.role, space, space.access_control);
if (!hasAccess) {
return error(res, 'FORBIDDEN', 'You are not authorized to access this space', 403);
}
// Get projects in space filtered by user's granular access
const isOwner = space.owner_id === req.user.id;
const isSysAdmin = req.user.role === 'admin' || req.user.role === 'owner';
// Owner and sys admins see all projects, others see filtered
const projects = (isOwner || isSysAdmin)
? await getProjectsBySpace(spaceId)
: await getProjectsBySpaceForUser(spaceId, req.user.id, space.access_control, space.owner_id);
// Get space dashboard
const dashboard = await getSpaceDashboard(spaceId);
success(res, {
space,
projects,
dashboard
});
} catch (err) {
apiLogger.error('Error fetching space:', err);
error(res, 'FETCH_ERROR', err.message, 500);
}
});
router.put('/:id', async (req, res) => {
try {
const spaceId = parseInt(req.params.id);
const { name, description, icon, type, theme_primary, theme_secondary, theme_tertiary, settings, access_control, tickets_config, files_config } = req.body;
const space = await getSpaceById(spaceId);
if (!space) {
return notFound(res, 'Space');
}
// Check access using SpaceService - only owner and admins can update
const isOwner = space.owner_id === req.user.id;
const isSysAdmin = req.user.role === 'admin' || req.user.role === 'owner';
const hasAccess = await checkUserSpaceAccess(req.user.id, req.user.role, space, space.access_control);
if (!isOwner && !isSysAdmin && !hasAccess) {
return error(res, 'FORBIDDEN', 'You are not authorized to update this space', 403);
}
// Prevent changing type
if (type && type !== space.type) {
return badRequest(res, 'Cannot change space type. Delete and recreate instead.', 'VALIDATION_ERROR');
}
const updated = await updateSpace(spaceId, {
name,
description,
icon,
theme_primary,
theme_secondary,
theme_tertiary,
settings,
access_control,
tickets_config,
files_config
});
success(res, updated);
} catch (err) {
apiLogger.error('Error updating space:', err);
error(res, 'UPDATE_ERROR', err.message, 500);
}
});
// PATCH alias — frontend uses apiClient.patch() for partial updates (e.g. tickets_config)
router.patch('/:id', async (req, res) => {
try {
apiLogger.info({ body: req.body, params: req.params }, 'PATCH /spaces/:id received');
const spaceId = parseInt(req.params.id);
const space = await getSpaceById(spaceId);
if (!space) return notFound(res, 'Space');
const isOwner = space.owner_id === req.user.id;
const isSysAdmin = req.user.role === 'admin' || req.user.role === 'owner';
const hasAccess = await checkUserSpaceAccess(req.user.id, req.user.role, space, space.access_control);
if (!isOwner && !isSysAdmin && !hasAccess) {
return error(res, 'FORBIDDEN', 'You are not authorized to update this space', 403);
}
const updated = await updateSpace(spaceId, req.body);
success(res, updated);
} catch (err) {
apiLogger.error('Error patching space:', err);
error(res, 'UPDATE_ERROR', err.message, 500);
}
});
router.delete('/:id', async (req, res) => {
try {
const spaceId = parseInt(req.params.id);
const space = await getSpaceById(spaceId);
if (!space) {
return notFound(res, 'Space');
}
// Only owner or system admin can delete space
const isOwner = space.owner_id === req.user.id;
const isSysAdmin = req.user.role === 'admin' || req.user.role === 'owner';
if (!isOwner && !isSysAdmin) {
return error(res, 'FORBIDDEN', 'You are not authorized to delete this space', 403);
}
await deleteSpace(spaceId);
success(res, { message: 'Space deleted successfully' });
} catch (err) {
apiLogger.error('Error deleting space:', err);
error(res, 'DELETE_ERROR', err.message, 500);
}
});
}