Task Breakdown: Templates Catalog
Feature ID: templates-catalog
Status: Done (Retrospective)
Last updated: 2026-05-08
Phase 1 — Schema & Repositories
- T1.
Templateentity atpackages/agent/src/entities/template.entity.tswith varchar(120) PK,kind/sourceTypediscriminators, optionalownerUserId, catalog metadata fields,branch(defaultmain),syncBranches(simple-json),betaBranch,isActive,metadata(simple-json). Indexes(kind, sourceType, isActive)and(ownerUserId, kind). - T2.
UserTemplatePreferenceentity atpackages/agent/src/entities/user-template-preference.entity.tswith uuid PK,(userId, kind)unique, FK-styletemplateId. - T3.
TemplateRepositoryatpackages/agent/src/database/repositories/template.repository.tswithfindById,findVisibleByKind(built-in + own-custom union,sourceType DESC, name ASC),findVisibleById,findOwnedCustomById,findOwnedCustomByRepositoryUrl,findOwnedCustomByRepositoryCoordinates,findBuiltInByRepositoryCoordinates,hasRecentDiscoveredBuiltInTemplates(Rawmetadata LIKEfilter ondiscoveredFromOrganization),upsert(conflict path onid),updateById. - T4.
UserTemplatePreferenceRepositoryatpackages/agent/src/database/repositories/user-template-preference.repository.tswithfindByUserAndKind,upsertDefault(conflict path on(userId, kind)),deleteByUserKindAndTemplateId.
Phase 2 — Service Surface
- T5.
TemplateCatalogServiceatpackages/agent/src/template-catalog/template-catalog.service.tsinjectingTemplateRepository,UserTemplatePreferenceRepository,WorkRepository,GitFacadeService. ImplementsOnModuleInit. ConstantWEBSITE_DISCOVERY_SYNC_TTL_MS = 1000 * 60 * 60(1 h). - T6.
onModuleInitwrapsseedBuiltInTemplates()in a try/catch and warn-logs on failure (Failed to seed built-in templates during startup: <msg>). - T7.
seedBuiltInTemplates: mapslistWebsiteTemplates()(which returnsClassicalways, plusMinimalenv-gated) throughtoBuiltInWebsiteTemplateRecord, andPromise.all-upserts each. LogsEnsured <N> built-in templates are presentat debug. - T8.
listTemplatesForUser(kind, userId): whenkind === 'website', runssyncDiscoveredWebsiteTemplatesIfStale(userId)BEFOREfindVisibleByKind+getDefaultTemplateIdForUserparallel reads. Returns{ defaultTemplateId, templates: TemplateCatalogItem[] }. - T9.
addCustomTemplate(input, userId):parseGitHubRepositoryUrl→ BadRequest'Only valid GitHub repository URLs are supported for custom templates.';findOwnedCustomByRepositoryUrl(kind, userId, canonicalUrl)→ Conflict'You already added this template repository.'; otherwiseupsert({ id: 'custom-' + randomUUID(), sourceType: 'custom', ownerUserId: userId, name: input.name?.trim() || humanizeRepositoryName(repo), description: input.description?.trim() || null, framework: input.framework?.trim() || inferFrameworkFromRepository(repo), previewImageUrl: input.previewImageUrl?.trim() || null, repositoryUrl: canonicalUrl, repositoryOwner: owner, repositoryName: repo, branch: input.branch?.trim() || 'main', syncBranches: input.syncBranches?.length ? input.syncBranches : [branch], betaBranch: input.betaBranch?.trim() || null, isActive: true, metadata: {} }). - T10.
updateCustomTemplateForUser({ kind, templateId, name?, description?, framework?, previewImageUrl?, branch?, betaBranch? }, userId):findOwnedCustomByIdthen guard(template.kind === input.kind && template.isActive)→ NotFound'Custom template not found for this user and kind.'. Field rules per FR-10:nameempty-string preserves existing,description/framework/previewImageUrl/betaBranchempty-string clear tonull,branchempty-string preserves existing; whenbranchchanges,syncBranches.length === 1is rewritten to[newBranch], otherwise the existing branch entry is replaced in-place. - T11.
archiveCustomTemplateForUser({ kind, templateId }, userId): samefindOwnedCustomByIdguard; forkind: 'website'runworkRepository.countByUserAndWebsiteTemplateId(userId, template.id)— Conflict (singular vs. plural copy) when> 0; then if the template is the user's current default, runworkRepository.countByUserAndInheritedWebsiteTemplateSelection( userId)— Conflict (singular vs. plural copy) when> 0. On success:updateById({ isActive: false })thenuserTemplatePreferenceRepository.deleteByUserKindAndTemplateId( userId, kind, template.id). Returns{ templateId, archived: true }. - T12.
setDefaultTemplateForUser(kind, templateId, userId):findVisibleById+ kind match → NotFound'Template not found for this user and kind.'. OtherwiseuserTemplatePreferenceRepository.upsertDefault(userId, kind, templateId). Returns{ defaultTemplateId: templateId }. - T13.
forkTemplateForUser({ kind, templateId, targetOwner }, userId): six error classes per FR-16; existing-fork short-circuit per FR-17; failedgitFacade.forkRepository→ BadRequest'Forking the selected template failed.'(FR-18); success upserts the row with the sevenmetadata.forkedFromXaudit fields per FR-19; sets the new fork as the user's default per FR-20. - T14.
getVisibleTemplateForUser(kind, templateId, userId):findVisibleById+ kind match → returnsTemplateCatalogItemornull(no exception — read-only lookup helper). - T15.
getDefaultTemplateIdForUser(kind, userId):userTemplatePreferenceRepository.findByUserAndKind→ iffindVisibleById(preference.templateId, userId)resolves to a visible kind-matched row, return its id. Otherwise: forkind: 'website'returngetDefaultWebsiteTemplateId()(env-driven,'classic'fallback); forkind: 'work'returnnull. - T16.
refreshTemplatesForUser(kind, userId): whenkind === 'website'runsyncDiscoveredWebsiteTemplatesForUser(userId)unconditionally; then returnlistTemplatesForUser(kind, userId).
Phase 3 — Discovery
- T17.
syncDiscoveredWebsiteTemplatesIfStale(userId):templateRepository.hasRecentDiscoveredBuiltInTemplates('website', catalogOwner, now - 1h)→ only whenfalserunssyncDiscoveredWebsiteTemplatesForUser(userId). - T18.
syncDiscoveredWebsiteTemplatesForUser(userId): tries to getaccessToken = await gitFacade.getAccessToken({ userId, providerId: 'github' }); walks pages 1..50 of 100 repos vialistRepositories({ providerId, userId, token: accessToken }, page, 100, { owner: catalogOwner, type: 'org' })(token path) ORlistPublicRepositories(providerId, page, 100, ...)(no-token fallback). Breaks early whenpageRepositories.length < 100. Warn-logs the 50-page safety cap. - T19. Filter discovered repositories via
isStandardTemplateRepository(repo)(/template$/i.test(repo.trim())). - T20. Reconcile discovered ids: prefer
findBuiltInByRepositoryCoordinates('website', repo.owner, repo.name)'s id as the canonical id; otherwiserepo.name.toLowerCase(). When the discovered id collides with an existing row whose coordinates differ, SKIP with warn log (Skipping discovered template "<fullName>" because id "<discoveredId>" is already used by …). - T21. Upsert each discovered template with
id: canonicalId, kind: 'website', sourceType: 'built_in', name: humanizeRepositoryName(repo.name), description: repo.description || null, framework: inferFrameworkFromRepository(repo.name), repositoryUrl: repo.url, repositoryOwner / Name, branch: repo.defaultBranch || 'main', syncBranches: [repo.defaultBranch || 'main'], betaBranch: null, isActive: true, metadata: { discoveredFromOrganization: catalogOwner, fullName: repo.fullName }. - T22. Deactivate the duplicate row when
canonicalId !== discoveredIdAND a discovered-id row exists with matching coordinates AND is currently active. - T23. Outer try/catch: warn-log
Failed to sync discovered website templates for user <userId>: <msg>and resolve so the calling list endpoint still completes.
Phase 4 — HTTP Surface
- T24.
TemplateCatalogControlleratapps/api/src/template-catalog/template-catalog.controller.tsmounted on/api, behind the globalAuthSessionGuard, injectingTemplateCatalogService+ActivityLogService. - T25.
GET /api/templatesaccepts?kind=<website|work>viaListTemplatesQueryDto; runstemplateCatalogService.listTemplatesForUser(query.kind, auth.userId); returns{ status: 'success', kind, defaultTemplateId, templates }. - T26.
POST /api/templates/customacceptsAddCustomTemplateDto; after success emits fire-and-forget activity-log{ actionType: TEMPLATE_ADDED, action: 'template.added', status: COMPLETED, summary: 'Added <kind> template: <name>', metadata: { templateId, kind } }.catch(() => {}). - T27.
PUT /api/templates/custom/:templateIdacceptsUpdateCustomTemplateDto; emitsTEMPLATE_UPDATED/template.updatedactivity-log entry on success. - T28.
POST /api/templates/custom/:templateId/archiveacceptsArchiveCustomTemplateDto; emitsTEMPLATE_ARCHIVED/template.archivedon success; returns{ status: 'success', templateId, archived: true }. - T29.
PUT /api/templates/defaultacceptsSetDefaultTemplateDto; emitsTEMPLATE_DEFAULT_SET/template.default_set; returns{ status: 'success', kind, defaultTemplateId }. - T30.
POST /api/templates/forkacceptsForkTemplateDto; emitsTEMPLATE_FORKED/template.forkedONLY whenresult.created === true(gated byif (result.created)in the controller); returns{ status: 'success', kind, defaultTemplateId, template, repository, created }. - T31.
POST /api/templates/refreshacceptsRefreshTemplatesDto; runsrefreshTemplatesForUser; returns{ status: 'success', kind, defaultTemplateId, templates }. NO activity-log emission. - T32. DTOs in
apps/api/src/template-catalog/dto/list-templates.dto.tswith@IsString+@IsIn(['website','work'])on everykindfield;@IsUrl({ protocols: ['http','https'], require_protocol: true })onrepositoryUrl/previewImageUrl;@IsOptional()on every optional field.
Phase 5 — Tests
- T33.
template-catalog.controller.spec.ts(apps/api) — covers all 7 endpoints' positional service args,AuthService/ activity-log fire-and-forget pattern,result.createdgate ontemplate.forked, error propagation. - T34.
template-catalog.service.spec.ts(packages/agent) — covers seed, list-with-stale-discovery gate, all six error classes onforkTemplateForUser, the existing-fork short-circuit, the sevenmetadata.forkedFromXaudit fields, the discovered-id collision skip, the deactivate-duplicate branch, the singular-vs-plural archive refusal copy, and thegetDefaultTemplateIdForUserfallback chain. - T35. FOLLOW-UP — Postgres-container integration test that walks the full happy path: seed runs, user adds a custom template, sets it as default, archives it (Conflict on assigned work, success after detach). Currently covered only at unit level with mocked repositories.
- T36. FOLLOW-UP — e2e test against
GET/POST /api/templates*endpoints, asserting theAuthSessionGuard401 path, the@IsIn400 path onkind, and the round-trip add-fork-archive cycle with a real GitHub stub.
Phase 6 — Open Questions / Outstanding Work
- T37. OQ-1 — Generalise the fork flow to non-GitHub git
providers. Today
providerId = 'github'is a literal inforkTemplateForUserandsyncDiscoveredWebsiteTemplatesForUser. - T38. OQ-2 — Decide the
kind: 'work'discovery convention (e.g.*work-templateGitHub-name suffix vs. separate organization). - T39. OQ-3 — Add an early reachability check (HTTP HEAD on
repositoryUrl) inaddCustomTemplateso users can't register private / non-existent repos that fail downstream in the website-generator clone path. - T40. OQ-4 — Skip the
getDefaultWebsiteTemplateId()fallback ingetDefaultTemplateIdForUserwhen the resolved seed default is no longer active (archived by the operator). - T41. OQ-5 — Decide whether
template.refreshedshould be logged for audit. Refreshes mutate persisted rows on a successful discovery pass.
Definition of Done
- All FRs in
spec.mdmap to a passing test. - All 7 endpoints have controller-level coverage.
TemplateCatalogServicehas agent-package unit coverage of every branch inaddCustomTemplate/updateCustomTemplateForUser/archiveCustomTemplateForUser/forkTemplateForUser/getDefaultTemplateIdForUser.- Discovery covers the canonical-vs-discovered id reconciliation branch and the duplicate-deactivation branch.
COVERAGE-TRACKER.mdreflects the spec landing under "Pending — Medium Priority → Spec Kit features that need a spec".