feat: public projects, project deletion and sagas
Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
public project without a token: project, board, tickets, comments,
history, releases, activity, tracking and statistics, over GraphQL and
REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
invalid tokens still rejected. ReadAccess lets anonymous viewers read
public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
notice, sign-in hints), public badge, visibility switch in settings,
no realtime subscriptions without a session.
Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
/api/projects/{project}?confirm=KEY). The key is freed; the read model
drops the project and members are notified (web goes home).
Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
the event store (EventRecorder); the tracker unassigns that user's open
tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
concurrency conflicts retried. Affected streams come from the event store
(TrackerStreamsRepository).
Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ Adaptadores de salida del tracker.
|
||||
|
||||
## Contenido
|
||||
|
||||
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store).
|
||||
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`), `ReleaseIssuesRepository` y
|
||||
`TrackerStreamsRepository` (consultan el event store: tickets y releases de un proyecto, comentarios y enlaces de un
|
||||
ticket).
|
||||
- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y
|
||||
`UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría
|
||||
del estado en los tickets proyectados antes de existir).
|
||||
|
||||
@@ -49,9 +49,10 @@ const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
function toProject(doc: ProjectDoc): ProjectReadModel {
|
||||
const { memberIds: _ignored, ...rest } = doc;
|
||||
// Projects projected before settings existed use the defaults.
|
||||
// Projects projected before settings (or visibility) existed use the defaults.
|
||||
return {
|
||||
...fromDoc(rest),
|
||||
isPublic: rest.isPublic ?? false,
|
||||
settings: rest.settings ?? defaultProjectSettings(),
|
||||
};
|
||||
}
|
||||
@@ -83,6 +84,14 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
|
||||
return docs.map(toProject);
|
||||
}
|
||||
|
||||
async findPublic(): Promise<ProjectReadModel[]> {
|
||||
const docs = await this.projects
|
||||
.find({ isPublic: true })
|
||||
.sort({ name: 1 })
|
||||
.lean<ProjectDoc[]>();
|
||||
return docs.map(toProject);
|
||||
}
|
||||
|
||||
async findRole(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
@@ -207,6 +216,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findOpenAssignedTo(userId: string): Promise<IssueReadModel[]> {
|
||||
const docs = await this.issues
|
||||
.find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED } })
|
||||
.lean<IssueDoc[]>();
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<IssueReadModel[]> {
|
||||
const docs = await this.issues.find({ projectId }).lean<IssueDoc[]>();
|
||||
return docs.map(issueFromDoc);
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ProjectSchema = new Schema<ProjectDoc>(
|
||||
{
|
||||
_id: String,
|
||||
key: { type: String, unique: true },
|
||||
isPublic: { type: Boolean, index: true },
|
||||
memberIds: { type: [String], index: true },
|
||||
owner: userRef,
|
||||
members: [{ _id: false, user: userRef, role: String }],
|
||||
|
||||
@@ -37,10 +37,22 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Project)
|
||||
private readonly projects: Model<ProjectDoc>,
|
||||
@InjectModel(ReadModels.Activity)
|
||||
private readonly activity: Model<ActivityDoc>,
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async remove(projectId: string) {
|
||||
await Promise.all([
|
||||
this.projects.deleteOne({ _id: projectId }),
|
||||
this.activity.deleteMany({ projectId }),
|
||||
this.timeline.deleteMany({ projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
async upsert({
|
||||
id,
|
||||
version,
|
||||
@@ -60,10 +72,28 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIssueRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async remove(issueId: string) {
|
||||
await Promise.all([
|
||||
this.issues.deleteOne({ _id: issueId }),
|
||||
this.issues.updateMany(
|
||||
{
|
||||
$or: [
|
||||
{ 'inwardLinks.issueId': issueId },
|
||||
{ 'outwardLinks.issueId': issueId },
|
||||
],
|
||||
},
|
||||
{ $pull: { inwardLinks: { issueId }, outwardLinks: { issueId } } },
|
||||
),
|
||||
this.timeline.deleteMany({ issueId }),
|
||||
]);
|
||||
}
|
||||
|
||||
async upsert({
|
||||
id,
|
||||
version,
|
||||
@@ -164,6 +194,10 @@ export class MongoUpsertMaterializedReleaseRepository extends UpsertMaterialized
|
||||
}: Pick<ReleaseReadModel, 'id' | 'version'> & Partial<ReleaseReadModel>) {
|
||||
await upsertIfNewer(this.releases, id, version, fields);
|
||||
}
|
||||
|
||||
async remove(releaseId: string) {
|
||||
await this.releases.deleteOne({ _id: releaseId });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -29,6 +29,12 @@ export class PostgresProjectKeysRepository extends ProjectKeysRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async release(key: string, projectId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(ProjectKeyEntity)
|
||||
.delete({ key, projectId });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TrackerStreamsRepository } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/** Reads the event store directly: which streams hang off a project or a ticket. */
|
||||
@Injectable()
|
||||
export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
super();
|
||||
}
|
||||
|
||||
issueIdsOfProject(projectId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
IssueCreatedEvent.name,
|
||||
`data->>'projectId'`,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
releaseIdsOfProject(projectId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
ReleaseCreatedEvent.name,
|
||||
`data->>'projectId'`,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
commentIdsOfIssue(issueId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
CommentAddedEvent.name,
|
||||
`data->'comment'->>'issueId'`,
|
||||
issueId,
|
||||
);
|
||||
}
|
||||
|
||||
issueIdsLinkingTo(issueId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
IssueLinkedEvent.name,
|
||||
`data->'link'->>'targetIssueId'`,
|
||||
issueId,
|
||||
);
|
||||
}
|
||||
|
||||
/** `field` is one of the fixed JSON paths above, never user input. */
|
||||
private async streams(
|
||||
type: string,
|
||||
field: string,
|
||||
value: string,
|
||||
): Promise<string[]> {
|
||||
const rows: { stream_id: string }[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT stream_id FROM events WHERE type = $1 AND ${field} = $2`,
|
||||
[type, value],
|
||||
);
|
||||
return rows.map((row) => row.stream_id);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProjectKeysRepository,
|
||||
ReleaseIssuesRepository,
|
||||
ReleaseNamesRepository,
|
||||
TrackerStreamsRepository,
|
||||
UpsertMaterializedActivityRepository,
|
||||
UpsertMaterializedCommentRepository,
|
||||
UpsertMaterializedIssueRepository,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
PostgresReleaseNamesRepository,
|
||||
} from './postgres/lookup.repositories';
|
||||
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
|
||||
const PORTS = [
|
||||
@@ -59,6 +61,10 @@ const PORTS = [
|
||||
provide: ReleaseIssuesRepository,
|
||||
useClass: PostgresReleaseIssuesRepository,
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useClass: PostgresTrackerStreamsRepository,
|
||||
},
|
||||
// Read side → MongoDB
|
||||
{ provide: FindProjectsRepository, useClass: MongoFindProjectsRepository },
|
||||
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
|
||||
|
||||
Reference in New Issue
Block a user