Skip to content

Commit b86a881

Browse files
authored
Merge pull request #637 from johanbook/organization-journal
feat(api): add organization journal
2 parents 01970ca + 8edbab2 commit b86a881

15 files changed

+206
-50
lines changed

services/api/scripts/generate-migration

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
set -e
55

66
DATA_SOURCE=dist/core/database/data-source.js
7-
MIGRATION_DIRECTORY=./src/core/database/migrations
7+
MIGRATION_DIRECTORY=./src/core/database/infrastructure/migrations
88
MIGRATION_NAME=$1
99

1010
# Check that `MIGRATION_NAME` is defined

services/api/scripts/run-migrations

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ set -e
66
sh ./scripts/create-database-if-not-exists
77

88
DATA_SOURCE=./dist/core/database/data-source.js
9-
MIGRATION_DIRECTORY=./dist/core/database/migrations
9+
MIGRATION_DIRECTORY=./dist/core/database/infrastructure/migrations
1010

1111
# Check that `MIGRATION_DIRECTORY` exists
1212
if [ ! -d "$MIGRATION_DIRECTORY" ]; then
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { IsString, Length } from "class-validator";
1+
import { JournalProfileDetails } from "./journal-profile-details.dto";
22

33
export class JournalEntryDetails {
4-
@IsString()
5-
@Length(0, 1024)
64
public readonly commandName!: string;
75

8-
public readonly created!: Date;
6+
public readonly createdAt!: Date;
97

10-
public readonly id!: number;
8+
public readonly id!: string;
119

1210
public readonly payload!: unknown;
11+
12+
public readonly profile!: JournalProfileDetails;
1313
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class JournalProfileDetails {
2+
public readonly id!: number;
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Type } from "class-transformer";
2+
import { IsDate } from "class-validator";
3+
4+
import { BaseQuery } from "src/core/query";
5+
6+
export class GetCurrentOrganizationJournalQuery extends BaseQuery {
7+
@IsDate()
8+
@Type(() => Date)
9+
from!: Date;
10+
11+
@IsDate()
12+
@Type(() => Date)
13+
to!: Date;
14+
}

services/api/src/core/journal/application/contracts/queries/get-journal.query.ts renamed to services/api/src/core/journal/application/contracts/queries/get-profile-journal.query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { IsDate } from "class-validator";
33

44
import { BaseQuery } from "src/core/query";
55

6-
export class GetJournalQuery extends BaseQuery {
6+
export class GetProfileJournalQuery extends BaseQuery {
77
@IsDate()
88
@Type(() => Date)
99
from!: Date;

services/api/src/core/journal/application/handlers/command-handlers/create-journal-entry.handler.spec.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { Repository } from "typeorm";
22

33
import { UserIdService } from "src/core/authentication";
44
import { map } from "src/core/mapper";
5+
import { CurrentOrganizationService } from "src/core/organizations";
6+
import { createCurrentOrganizationMock } from "src/core/organizations/test/mocks/current-organization.service.mock";
7+
import { CurrentProfileService, Profile } from "src/core/profiles";
58
import { createMockRepository, createUserIdServiceMock } from "src/test/mocks";
69

710
import { JournalEntry } from "../../../infrastructure/entities/journal-entry.entity";
@@ -11,15 +14,24 @@ import { CreateJournalEntryHandler } from "./create-journal-entry.handler";
1114
describe(CreateJournalEntryHandler.name, () => {
1215
let commandHandler: CreateJournalEntryHandler;
1316
let journalEntries: Repository<JournalEntry>;
17+
let currentOrganizationService: CurrentOrganizationService;
18+
let currentProfileService: CurrentProfileService;
19+
let profiles: Repository<Profile>;
1420
let userIdService: UserIdService;
1521

1622
beforeEach(() => {
23+
profiles = createMockRepository<Profile>([{} as any]);
1724
journalEntries = createMockRepository<JournalEntry>();
1825
userIdService = createUserIdServiceMock();
1926

27+
currentOrganizationService = createCurrentOrganizationMock();
28+
29+
currentProfileService = new CurrentProfileService(profiles, userIdService);
30+
2031
commandHandler = new CreateJournalEntryHandler(
32+
currentOrganizationService,
33+
currentProfileService,
2134
journalEntries,
22-
userIdService,
2335
);
2436
});
2537

services/api/src/core/journal/application/handlers/command-handlers/create-journal-entry.handler.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { NotFoundException } from "@nestjs/common";
12
import { CommandHandler, ICommandHandler } from "@nestjs/cqrs";
23
import { InjectRepository } from "@nestjs/typeorm";
34
import { Repository } from "typeorm";
45

5-
import { UserIdService } from "src/core/authentication";
6-
import { MissingUserIdError } from "src/core/authentication";
6+
import { CurrentOrganizationService } from "src/core/organizations";
7+
import { CurrentProfileService } from "src/core/profiles";
78
import { redactBinaries } from "src/utils/object.helper";
89

910
import { JournalEntry } from "../../../infrastructure/entities/journal-entry.entity";
@@ -14,20 +15,24 @@ export class CreateJournalEntryHandler
1415
implements ICommandHandler<CreateJournalEntryCommand, void>
1516
{
1617
constructor(
18+
private readonly currentOrganizationService: CurrentOrganizationService,
19+
private readonly currentProfileService: CurrentProfileService,
1720
@InjectRepository(JournalEntry)
1821
private readonly journalEntries: Repository<JournalEntry>,
19-
private readonly userIdService: UserIdService,
2022
) {}
2123

2224
async execute(command: CreateJournalEntryCommand) {
23-
let userId: string;
25+
let organizationId;
26+
let profileId;
2427

2528
// User ID will not be available for system-issued commands (e.g. event handlers)
2629
// This skips logging any commands where user id cannot be found
2730
try {
28-
userId = this.userIdService.getUserId();
31+
organizationId =
32+
await this.currentOrganizationService.fetchCurrentOrganizationId();
33+
profileId = await this.currentProfileService.fetchCurrentProfileId();
2934
} catch (error) {
30-
if (error instanceof MissingUserIdError) {
35+
if (error instanceof NotFoundException) {
3136
return;
3237
}
3338

@@ -37,8 +42,9 @@ export class CreateJournalEntryHandler
3742
const newJournalEntry = new JournalEntry();
3843

3944
newJournalEntry.commandName = command.commandName;
45+
newJournalEntry.organizationId = organizationId;
4046
newJournalEntry.payload = redactBinaries(command.payload);
41-
newJournalEntry.userId = userId;
47+
newJournalEntry.profileId = profileId;
4248

4349
await this.journalEntries.save(newJournalEntry);
4450
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { IQueryHandler, QueryHandler } from "@nestjs/cqrs";
2+
import { InjectRepository } from "@nestjs/typeorm";
3+
import { Between, Repository } from "typeorm";
4+
5+
import { map, mapArray } from "src/core/mapper";
6+
import { CurrentOrganizationService } from "src/core/organizations";
7+
import { QueryService } from "src/core/query";
8+
9+
import { JournalEntry } from "../../../infrastructure/entities/journal-entry.entity";
10+
import { JournalDetails } from "../../contracts/dtos/journal-details.dto";
11+
import { JournalEntryDetails } from "../../contracts/dtos/journal-entry-details.dto";
12+
import { JournalProfileDetails } from "../../contracts/dtos/journal-profile-details.dto";
13+
import { GetCurrentOrganizationJournalQuery } from "../../contracts/queries/get-current-organization-journal.query";
14+
15+
function formatCommandName(commandName: string): string {
16+
return commandName.replace(/Command$/, "");
17+
}
18+
19+
@QueryHandler(GetCurrentOrganizationJournalQuery)
20+
export class GetCurrentOrganizationJournalHandler
21+
implements IQueryHandler<GetCurrentOrganizationJournalQuery, JournalDetails>
22+
{
23+
constructor(
24+
private readonly currentOrganizationService: CurrentOrganizationService,
25+
@InjectRepository(JournalEntry)
26+
private readonly journalEntries: Repository<JournalEntry>,
27+
private readonly queryService: QueryService<JournalEntry>,
28+
) {}
29+
30+
async execute(query: GetCurrentOrganizationJournalQuery) {
31+
const currentOrganizationId =
32+
await this.currentOrganizationService.fetchCurrentOrganizationId();
33+
34+
const foundJournalEntries = await this.queryService.find(
35+
this.journalEntries,
36+
{
37+
default: {
38+
order: { createdAt: "desc" },
39+
},
40+
query,
41+
required: {
42+
where: {
43+
createdAt: Between(query.from, query.to),
44+
organizationId: currentOrganizationId,
45+
},
46+
},
47+
},
48+
);
49+
50+
return map(JournalDetails, {
51+
entries: mapArray(JournalEntryDetails, foundJournalEntries, (entry) => ({
52+
commandName: formatCommandName(entry.commandName),
53+
createdAt: entry.createdAt,
54+
id: entry.id,
55+
payload: entry.payload,
56+
profile: map(JournalProfileDetails, {
57+
id: entry.profileId,
58+
}),
59+
})),
60+
});
61+
}
62+
}

services/api/src/core/journal/application/handlers/query-handlers/get-journal.handler.ts renamed to services/api/src/core/journal/application/handlers/query-handlers/get-profile-journal.handler.ts

+18-13
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,47 @@
11
import { IQueryHandler, QueryHandler } from "@nestjs/cqrs";
22
import { InjectRepository } from "@nestjs/typeorm";
3-
import { And, LessThan, MoreThan, Repository } from "typeorm";
3+
import { Between, Repository } from "typeorm";
44

5-
import { UserIdService } from "src/core/authentication";
65
import { map, mapArray } from "src/core/mapper";
6+
import { CurrentProfileService } from "src/core/profiles";
77
import { QueryService } from "src/core/query";
88

99
import { JournalEntry } from "../../../infrastructure/entities/journal-entry.entity";
1010
import { JournalDetails } from "../../contracts/dtos/journal-details.dto";
1111
import { JournalEntryDetails } from "../../contracts/dtos/journal-entry-details.dto";
12-
import { GetJournalQuery } from "../../contracts/queries/get-journal.query";
12+
import { JournalProfileDetails } from "../../contracts/dtos/journal-profile-details.dto";
13+
import { GetProfileJournalQuery } from "../../contracts/queries/get-profile-journal.query";
1314

1415
function formatCommandName(commandName: string): string {
1516
return commandName.replace(/Command$/, "");
1617
}
1718

18-
@QueryHandler(GetJournalQuery)
19-
export class GetJournalHandler
20-
implements IQueryHandler<GetJournalQuery, JournalDetails>
19+
@QueryHandler(GetProfileJournalQuery)
20+
export class GetProfileJournalHandler
21+
implements IQueryHandler<GetProfileJournalQuery, JournalDetails>
2122
{
2223
constructor(
24+
private readonly currentProfileService: CurrentProfileService,
2325
@InjectRepository(JournalEntry)
2426
private readonly journalEntries: Repository<JournalEntry>,
2527
private readonly queryService: QueryService<JournalEntry>,
26-
private readonly userIdService: UserIdService,
2728
) {}
2829

29-
async execute(query: GetJournalQuery) {
30-
const userId = this.userIdService.getUserId();
30+
async execute(query: GetProfileJournalQuery) {
31+
const currentProfileId =
32+
await this.currentProfileService.fetchCurrentProfileId();
3133

3234
const foundJournalEntries = await this.queryService.find(
3335
this.journalEntries,
3436
{
3537
default: {
36-
order: { created: "desc" },
38+
order: { createdAt: "desc" },
3739
},
3840
query,
3941
required: {
4042
where: {
41-
created: And(LessThan(query.to), MoreThan(query.from)),
42-
userId,
43+
createdAt: Between(query.from, query.to),
44+
profileId: currentProfileId,
4345
},
4446
},
4547
},
@@ -48,9 +50,12 @@ export class GetJournalHandler
4850
return map(JournalDetails, {
4951
entries: mapArray(JournalEntryDetails, foundJournalEntries, (entry) => ({
5052
commandName: formatCommandName(entry.commandName),
51-
created: entry.created,
53+
createdAt: entry.createdAt,
5254
id: entry.id,
5355
payload: entry.payload,
56+
profile: map(JournalProfileDetails, {
57+
id: entry.profileId,
58+
}),
5459
})),
5560
});
5661
}

services/api/src/core/journal/client/controllers/journal.controller.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@ import { QueryBus } from "@nestjs/cqrs";
33
import { ApiTags } from "@nestjs/swagger";
44

55
import { JournalDetails } from "../../application/contracts/dtos/journal-details.dto";
6-
import { GetJournalQuery } from "../../application/contracts/queries/get-journal.query";
6+
import { GetCurrentOrganizationJournalQuery } from "../../application/contracts/queries/get-current-organization-journal.query";
7+
import { GetProfileJournalQuery } from "../../application/contracts/queries/get-profile-journal.query";
78

89
@Controller("journal")
910
@ApiTags("journal")
1011
export class JournalController {
1112
constructor(private queryBus: QueryBus) {}
1213

13-
@Get()
14-
async getJournal(@Query() query: GetJournalQuery): Promise<JournalDetails> {
14+
@Get("/current-organization")
15+
async getCurrentOrganizationJournal(
16+
@Query() query: GetCurrentOrganizationJournalQuery,
17+
): Promise<JournalDetails> {
18+
return await this.queryBus.execute(query);
19+
}
20+
21+
@Get("/profile")
22+
async getProfileJournal(
23+
@Query() query: GetProfileJournalQuery,
24+
): Promise<JournalDetails> {
1525
return await this.queryBus.execute(query);
1626
}
1727
}
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1-
import {
2-
CreateDateColumn,
3-
Column,
4-
Entity,
5-
PrimaryGeneratedColumn,
6-
} from "typeorm";
1+
import { Column, Entity, ManyToOne } from "typeorm";
72

8-
@Entity()
9-
export class JournalEntry {
10-
@PrimaryGeneratedColumn()
11-
id!: number;
12-
13-
@CreateDateColumn()
14-
created!: Date;
3+
import { BaseEntity } from "src/core/database";
4+
import { Organization } from "src/core/organizations";
5+
import { Profile } from "src/core/profiles";
156

7+
@Entity()
8+
export class JournalEntry extends BaseEntity {
169
@Column("text")
1710
commandName!: string;
1811

12+
@ManyToOne(() => Organization)
13+
organization!: Organization;
14+
15+
@Column()
16+
organizationId!: number;
17+
1918
@Column("json")
2019
payload!: unknown;
2120

22-
@Column("uuid")
23-
userId!: string;
21+
@ManyToOne(() => Profile)
22+
profile!: Profile;
23+
24+
@Column()
25+
profileId!: number;
2426
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
import { MigrationError } from "src/core/error-handling";
4+
5+
export class DropJournalTable1699731481699 implements MigrationInterface {
6+
name = 'DropJournalTable1699731481699'
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(`DROP TABLE "journal_entry"`);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
throw new MigrationError("Not supported")
14+
}
15+
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class CreateJournalEntryV21699732429511 implements MigrationInterface {
4+
name = 'CreateJournalEntryV21699732429511'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`CREATE TABLE "journal_entry" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "commandName" text NOT NULL, "organizationId" integer NOT NULL, "payload" json NOT NULL, "profileId" integer NOT NULL, CONSTRAINT "PK_69167f660c807d2aa178f0bd7e6" PRIMARY KEY ("id"))`);
8+
await queryRunner.query(`ALTER TABLE "journal_entry" ADD CONSTRAINT "FK_8ab2ce48d25de7470897d4970f3" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
9+
await queryRunner.query(`ALTER TABLE "journal_entry" ADD CONSTRAINT "FK_6747365996c833749ba53dd39f8" FOREIGN KEY ("profileId") REFERENCES "profile"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(`ALTER TABLE "journal_entry" DROP CONSTRAINT "FK_6747365996c833749ba53dd39f8"`);
14+
await queryRunner.query(`ALTER TABLE "journal_entry" DROP CONSTRAINT "FK_8ab2ce48d25de7470897d4970f3"`);
15+
await queryRunner.query(`DROP TABLE "journal_entry"`);
16+
}
17+
18+
}

0 commit comments

Comments
 (0)