Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/routes/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ export const router = createRouter()
}),
validator("query", GetProjects.QuerySchema),
async (c) => {
const { limit, offset } = c.req.valid("query");
const { limit, offset, status } = c.req.valid("query");

const db = c.get("db");
assert(db, "Database must be provided via middleware.");

const data = await getProjects(db, { limit, offset });
const data = await getProjects(db, { limit, offset, status });

const payload = await validate(GetProjects.ResponseSchema, data, 500);

Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/routes/projects/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,19 @@ export const ProjectSlugListSchema = v.pipe(

export type ProjectSlugList = v.InferOutput<typeof ProjectSlugListSchema>;

export const ProjectQuerySchema = v.object({
...PaginationQuerySchema.entries,
status: v.pipe(
v.optional(v.picklist(["active", "inactive"] as const)),
v.description(
"Filter by active (project duration contains current time) or inactive (project duration has ended)",
),
v.metadata({ ref: "ProjectStatusParam" }),
),
});

export const GetProjects = {
QuerySchema: PaginationQuerySchema,
QuerySchema: ProjectQuerySchema,
ResponseSchema: v.pipe(
v.object({
...PaginatedResponseSchema.entries,
Expand Down
23 changes: 20 additions & 3 deletions apps/api/src/routes/projects/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */

import { count, eq } from "@dariah-eric/database";
import { and, count, eq, not, sql } from "@dariah-eric/database";
import * as schema from "@dariah-eric/database/schema";

import { getContentBlocks } from "@/lib/content-blocks";
Expand All @@ -13,10 +13,11 @@ interface GetProjectsParams {
limit?: number;
/** @default 0 */
offset?: number;
status?: "active" | "inactive";
}

export async function getProjects(db: Database | Transaction, params: GetProjectsParams) {
const { limit = 10, offset = 0 } = params;
const { limit = 10, offset = 0, status } = params;

const [items, aggregate] = await Promise.all([
db.query.projects.findMany({
Expand All @@ -26,6 +27,13 @@ export async function getProjects(db: Database | Transaction, params: GetProject
type: "published",
},
},
RAW:
status != null
? (t) => {
const durationContainsNow = sql`${t.duration} @> NOW()::TIMESTAMPTZ`;
return status === "active" ? durationContainsNow : not(durationContainsNow);
}
: undefined,
},
columns: {
id: true,
Expand Down Expand Up @@ -78,7 +86,16 @@ export async function getProjects(db: Database | Transaction, params: GetProject
.from(schema.projects)
.innerJoin(schema.entities, eq(schema.projects.id, schema.entities.id))
.innerJoin(schema.entityStatus, eq(schema.entities.statusId, schema.entityStatus.id))
.where(eq(schema.entityStatus.type, "published")),
.where(
and(
eq(schema.entityStatus.type, "published"),
status != null
? status === "active"
? sql`${schema.projects.duration} @> NOW()::TIMESTAMPTZ`
: not(sql`${schema.projects.duration} @> NOW()::TIMESTAMPTZ`)
: undefined,
),
),
]);

const total = aggregate.at(0)?.total ?? 0;
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/routes/working-groups/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ export const router = createRouter()
}),
validator("query", GetWorkingGroups.QuerySchema),
async (c) => {
const { limit, offset } = c.req.valid("query");
const { limit, offset, status } = c.req.valid("query");

const db = c.get("db");
assert(db, "Database must be provided via middleware.");

const data = await getWorkingGroups(db, { limit, offset });
const data = await getWorkingGroups(db, { limit, offset, status });

const payload = await validate(GetWorkingGroups.ResponseSchema, data, 500);

Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/routes/working-groups/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,19 @@ export const WorkingGroupSlugListSchema = v.pipe(

export type WorkingGroupSlugList = v.InferOutput<typeof WorkingGroupSlugListSchema>;

export const WorkingGroupQuerySchema = v.object({
...PaginationQuerySchema.entries,
status: v.pipe(
v.optional(v.picklist(["active", "inactive"] as const)),
v.description(
"Filter by active (membership duration contains current time) or inactive (membership duration has ended)",
),
v.metadata({ ref: "WorkingGroupStatusParam" }),
),
});

export const GetWorkingGroups = {
QuerySchema: PaginationQuerySchema,
QuerySchema: WorkingGroupQuerySchema,
ResponseSchema: v.pipe(
v.object({
...PaginatedResponseSchema.entries,
Expand Down
55 changes: 52 additions & 3 deletions apps/api/src/routes/working-groups/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */

import { count, eq } from "@dariah-eric/database";
import { and, count, eq, exists, not, sql, type SQLWrapper } from "@dariah-eric/database";
import * as schema from "@dariah-eric/database/schema";

import { getContentBlocks } from "@/lib/content-blocks";
Expand All @@ -13,10 +13,48 @@ interface GetWorkingGroupsParams {
limit?: number;
/** @default 0 */
offset?: number;
status?: "active" | "inactive";
}

function buildStatusFilter(
db: Database | Transaction,
idRef: SQLWrapper,
status: "active" | "inactive",
) {
const durationContainsNow = sql`
${schema.organisationalUnitsRelations.duration} @> NOW()::TIMESTAMPTZ
`;
const durationCondition = status === "active" ? durationContainsNow : not(durationContainsNow);

return exists(
db
.select({ one: sql<number>`1` })
.from(schema.organisationalUnitsRelations)
.innerJoin(
schema.organisationalUnitStatus,
eq(schema.organisationalUnitsRelations.status, schema.organisationalUnitStatus.id),
)
.innerJoin(
schema.organisationalUnits,
eq(schema.organisationalUnitsRelations.relatedUnitId, schema.organisationalUnits.id),
)
.innerJoin(
schema.organisationalUnitTypes,
eq(schema.organisationalUnits.typeId, schema.organisationalUnitTypes.id),
)
.where(
and(
eq(schema.organisationalUnitsRelations.unitId, idRef),
eq(schema.organisationalUnitStatus.status, "is_part"),
eq(schema.organisationalUnitTypes.type, "umbrella_consortium"),
durationCondition,
),
),
);
}

export async function getWorkingGroups(db: Database | Transaction, params: GetWorkingGroupsParams) {
const { limit = 10, offset = 0 } = params;
const { limit = 10, offset = 0, status } = params;

const [items, aggregate] = await Promise.all([
db.query.workingGroups.findMany({
Expand All @@ -26,6 +64,12 @@ export async function getWorkingGroups(db: Database | Transaction, params: GetWo
type: "published",
},
},
RAW:
status != null
? (t) => {
return buildStatusFilter(db, t.id, status);
}
: undefined,
},
columns: {
id: true,
Expand Down Expand Up @@ -73,7 +117,12 @@ export async function getWorkingGroups(db: Database | Transaction, params: GetWo
.from(schema.workingGroups)
.innerJoin(schema.entities, eq(schema.workingGroups.id, schema.entities.id))
.innerJoin(schema.entityStatus, eq(schema.entities.statusId, schema.entityStatus.id))
.where(eq(schema.entityStatus.type, "published")),
.where(
and(
eq(schema.entityStatus.type, "published"),
status != null ? buildStatusFilter(db, schema.workingGroups.id, status) : undefined,
),
),
]);

const total = aggregate.at(0)?.total ?? 0;
Expand Down
90 changes: 90 additions & 0 deletions apps/api/test/api-projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,54 @@ async function seed(db: Database, items: ReturnType<typeof createItems>) {
);
}

async function seedWithMixedStatuses(db: Database) {
const [status, entityType, scope] = await Promise.all([
db.query.entityStatus.findFirst({ columns: { id: true }, where: { type: "published" } }),
db.query.entityTypes.findFirst({ columns: { id: true }, where: { type: "projects" } }),
db.query.projectScopes.findFirst({ columns: { id: true } }),
]);

assert(status, "No entity status in database.");
assert(entityType, "No entity type in database.");
assert(scope, "No project scope in database.");

const activeItem = createItems(1)[0]!;
const inactiveItem = (() => {
const id = uuidv7();
const documentId = uuidv7();
const name = f.lorem.sentence();
const slug = slugify(name);
const start = f.date.past({ years: 5 });
return {
entity: { id, slug, documentId },
project: {
id,
name,
summary: f.lorem.paragraph(),
call: f.lorem.word(),
topic: f.lorem.word(),
duration: { start, end: f.date.past({ years: 1 }) },
},
};
})();

const allItems = [activeItem, inactiveItem];

await db.insert(schema.entities).values(
allItems.map((item) => {
return { ...item.entity, statusId: status.id, typeId: entityType.id };
}),
);

await db.insert(schema.projects).values(
allItems.map((item) => {
return { ...item.project, scopeId: scope.id };
}),
);

return { activeItem, inactiveItem };
}

async function seedSocialMedia(db: Database, projectId: string) {
const type = await db.query.socialMediaTypes.findFirst({
columns: { id: true },
Expand Down Expand Up @@ -225,6 +273,48 @@ describe("projects", () => {
);
});
});

it("should return only active projects when status=active", async () => {
await withTransaction(async (db) => {
const client = createTestClient(db);
const { activeItem, inactiveItem } = await seedWithMixedStatuses(db);

const response = await client.projects.$get({
query: { status: "active" },
});

expect(response.status).toBe(200);
const data = await response.json();

expect(data.data).toEqual(
expect.arrayContaining([expect.objectContaining({ name: activeItem.project.name })]),
);
expect(data.data).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: inactiveItem.project.name })]),
);
});
});

it("should return only inactive projects when status=inactive", async () => {
await withTransaction(async (db) => {
const client = createTestClient(db);
const { activeItem, inactiveItem } = await seedWithMixedStatuses(db);

const response = await client.projects.$get({
query: { status: "inactive" },
});

expect(response.status).toBe(200);
const data = await response.json();

expect(data.data).toEqual(
expect.arrayContaining([expect.objectContaining({ name: inactiveItem.project.name })]),
);
expect(data.data).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: activeItem.project.name })]),
);
});
});
});

describe("GET /api/projects/:id", () => {
Expand Down
Loading
Loading