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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ GOOGLE_CLIENT_SECRET=""
# codebase.
# VIGIEPROCURE_API_URL="https://api.vigieproc.fr"
# VIGIEPROCURE_API_JWT=""
#
# Same pair also backs the Marches publics page (tenders.listOuverts ->
# GET /api/v1/tenders?status=active) -- one JWT, several read-only VigieProcure
# callers, cf. vigieprocure-tenders.client.ts.

# PORT="3001"

Expand Down
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.17.0"
".": "1.18.0"
}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [1.18.0](https://github.com/franckh-stack/crm/compare/v1.17.0...v1.18.0) (2026-09-13)


### Features

* **tenders:** page CRM Marches publics via API VigieProcure scopee ([a140bfb](https://github.com/franckh-stack/crm/commit/a140bfbef39a4d918334c0e27cda2f539c33c4e6))
* **tenders:** page CRM Marches publics via API VigieProcure scopee ([6c43130](https://github.com/franckh-stack/crm/commit/6c43130e60836d39c57db90e00843183ec53a4a0))

## [1.17.0](https://github.com/franckh-stack/crm/compare/v1.16.0...v1.17.0) (2026-09-12)


Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SlackModule } from "./slack/slack.module";
import { SsoModule } from "./sso/sso.module";
import { SyncModule } from "./sync/sync.module";
import { TelemetryModule } from "./telemetry/telemetry.module";
import { TendersModule } from "./tenders/tenders.module";
import { TrackingModule } from "./tracking/tracking.module";
import { TrpcModule } from "./trpc/trpc.module";
import { UsersModule } from "./users/users.module";
Expand Down Expand Up @@ -79,6 +80,7 @@ import { WorkspaceModule } from "./workspace/workspace.module";
TrackingModule,
ArchiveModule,
SavedViewsModule,
TendersModule,
],
})
export class AppModule {}
7 changes: 7 additions & 0 deletions apps/api/src/generated/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { savedViewListInput, savedViewListOutput, savedViewCreateInput, savedVie
import { agentModelOutput, modelCatalogOutput, setAgentModelInput, researchKeyOutput, setResearchKeyInput, archiveRetentionOutput, setArchiveRetentionDaysInput } from "../settings/settings.contracts";
import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannelsOutput, slackJoinChannelInput, slackJoinChannelOutput, slackRefreshPeopleOutput, slackCreateChannelInput, slackCreateChannelOutput, slackDisconnectOutput } from "../slack/slack.contracts";
import { ssoSignInOptionsOutput, ssoSettingsOutput, ssoProviderListInput, ssoProviderListOutput, registerSsoProviderInput, ssoProviderOutput, deleteSsoProviderInput, deleteSsoProviderOutput } from "../sso/sso.contracts";
import { tendersListOuvertsInput, tendersListOuvertsOutput } from "../tenders/tenders.contracts";
import { trackingSettingsOutput, trackingFlagInput, cookieLifetimeInput, addDomainInput, trackedDomainOutput, removeDomainInput, rotateSiteIdOutput, verifyInput, verifyOutput, sourcesOutput, companyActivityInput, websiteActivityOutput, contactActivityInput } from "../tracking/tracking.contracts";
import { workspaceOutput, memberListInput, memberListOutput, updateWorkspaceInput, setMemberRoleInput, workspaceMemberOutput } from "../workspace/workspace.contracts";
import type { UsersRouter } from "../users/users.router";
Expand Down Expand Up @@ -697,6 +698,12 @@ const appRouter = t.router({
.output(deleteSsoProviderOutput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any)
}),
tenders: t.router({
listOuverts: publicProcedure
.input(tendersListOuvertsInput)
.output(tendersListOuvertsOutput)
.query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any)
}),
tracking: t.router({
settings: publicProcedure
.output(trackingSettingsOutput)
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/tenders/tenders.contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from "zod";

// Bornes calquees sur api_v2/routers/tenders.py::list_tenders -- valider ici
// evite d'expedier une requete que l'API VigieProcure refusera de toute
// facon, et donne une erreur Zod lisible cote CRM plutot qu'un 422 distant.
export const tendersListOuvertsInput = z.object({
cpv: z.string().trim().min(2).max(400).optional(),
department: z.string().trim().min(1).max(3).optional(),
q: z.string().trim().min(2).max(200).optional(),
page: z.number().int().min(1).max(500).default(1),
limit: z.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});

export type TendersListOuvertsInput = z.infer<typeof tendersListOuvertsInput>;

const tenderOutput = z.object({
id: z.string(),
source: z.string().nullable(),
sourceId: z.string().nullable(),
title: z.string().nullable(),
titleDisplay: z.string().nullable(),
cpvCode: z.string().nullable(),
procedureType: z.string().nullable(),
amountEstimated: z.number().nullable(),
department: z.string().nullable(),
publishedAt: z.string().nullable(),
deadlineAt: z.string().nullable(),
status: z.string().nullable(),
buyerId: z.string().nullable(),
buyerName: z.string().nullable(),
updatedAt: z.string().nullable(),
});

const tendersPageOutput = z.object({
items: z.array(tenderOutput),
count: z.number(),
total: z.number(),
totalEstPlafonne: z.boolean(),
page: z.number(),
pageSize: z.number(),
nextCursor: z.string().nullable(),
});

export const tendersListOuvertsOutput = z.discriminatedUnion("outcome", [
z.object({ outcome: z.literal("not-configured") }),
z.object({ outcome: z.literal("unauthorized"), reason: z.string() }),
z.object({ outcome: z.literal("failed"), reason: z.string() }),
z.object({ outcome: z.literal("ok"), page: tendersPageOutput }),
]);

export type TendersListOuvertsOutput = z.infer<typeof tendersListOuvertsOutput>;
10 changes: 10 additions & 0 deletions apps/api/src/tenders/tenders.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { TrpcModule } from "../trpc/trpc.module";
import { TendersRouter } from "./tenders.router";
import { TendersService } from "./tenders.service";

@Module({
imports: [TrpcModule],
providers: [TendersService, TendersRouter],
})
export class TendersModule {}
27 changes: 27 additions & 0 deletions apps/api/src/tenders/tenders.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Inject } from "@nestjs/common";
import { Input, Query, Router, UseMiddlewares } from "nestjs-trpc";
import type { z } from "zod";
import { AuthMiddleware } from "../trpc/middlewares/auth.middleware";
import { restMeta } from "../trpc/openapi";
import {
tendersListOuvertsInput,
tendersListOuvertsOutput,
} from "./tenders.contracts";
import { TendersService } from "./tenders.service";

@Router({ alias: "tenders" })
@UseMiddlewares(AuthMiddleware)
export class TendersRouter {
constructor(
@Inject(TendersService) private readonly tenders: TendersService,
) {}

@Query({
input: tendersListOuvertsInput,
output: tendersListOuvertsOutput,
meta: restMeta("GET", "/tenders/ouverts", ["Tenders"]),
})
async listOuverts(@Input() input: z.infer<typeof tendersListOuvertsInput>) {
return this.tenders.listOuverts(input);
}
}
25 changes: 25 additions & 0 deletions apps/api/src/tenders/tenders.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import type { TendersListOuvertsInput } from "./tenders.contracts";
import {
listOpenTenders,
type TendersResult,
} from "./vigieprocure-tenders.client";

@Injectable()
export class TendersService {
/**
* Proxy en lecture seule vers l'API VigieProcure. Ne persiste rien --
* c'est un simple relai, pas de table Prisma dediee (cf. doctrine du
* chantier "bouton CRM -> API VigieProcure marches ouverts").
*/
async listOuverts(input: TendersListOuvertsInput): Promise<TendersResult> {
return listOpenTenders({
cpv: input.cpv,
department: input.department,
q: input.q,
page: input.page,
limit: input.limit,
cursor: input.cursor,
});
}
}
185 changes: 185 additions & 0 deletions apps/api/src/tenders/vigieprocure-tenders.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { z } from "zod";

const TENDERS_TIMEOUT_MS = 15_000;
const TENDERS_PATH = "/api/v1/tenders";

export interface VigieProcureTendersApi {
url: URL;
jwt: string;
}

/**
* `VIGIEPROCURE_API_JWT` unset means there is no way to call VigieProcure,
* not an unauthenticated call to it -- same rule as `vigieProcureApi()` in
* `companies/vigieprocure-companies.client.ts` (resolveSiren/setSiren) and
* `bridge()`/`vigieProcureBridge()` elsewhere. Every caller has to say what
* it does without VigieProcure.
*/
export function vigieProcureTendersApi(): VigieProcureTendersApi | null {
const jwt = process.env.VIGIEPROCURE_API_JWT?.trim();
const base = process.env.VIGIEPROCURE_API_URL?.trim();
if (!jwt || !base) return null;

return { url: new URL(TENDERS_PATH, base), jwt };
}

const tenderItem = z
.object({
id: z.string(),
source: z.string().nullable().catch(null),
source_id: z.string().nullable().catch(null),
title: z.string().nullable().catch(null),
title_display: z.string().nullable().catch(null),
cpv_code: z.string().nullable().catch(null),
procedure_type: z.string().nullable().catch(null),
amount_estimated: z.number().nullable().catch(null),
department: z.string().nullable().catch(null),
published_at: z.string().nullable().catch(null),
deadline_at: z.string().nullable().catch(null),
status: z.string().nullable().catch(null),
buyer_id: z.string().nullable().catch(null),
buyer_name: z.string().nullable().catch(null),
updated_at: z.string().nullable().catch(null),
})
.transform((raw) => ({
id: raw.id,
source: raw.source,
sourceId: raw.source_id,
title: raw.title,
// `title_display` retire le prefixe source TED ("France - <CPV> - ")
// quand il est present ; repli sur `title` sinon (BOAMP, data_gouv,
// TED sans prefixe). Cf. api_v2/routers/tenders.py::list_tenders.
titleDisplay: raw.title_display ?? raw.title,
cpvCode: raw.cpv_code,
procedureType: raw.procedure_type,
amountEstimated: raw.amount_estimated,
department: raw.department,
publishedAt: raw.published_at,
deadlineAt: raw.deadline_at,
status: raw.status,
buyerId: raw.buyer_id,
buyerName: raw.buyer_name,
updatedAt: raw.updated_at,
}));

export type VigieProcureTender = z.infer<typeof tenderItem>;

const tendersResponse = z.object({
items: z.array(tenderItem).catch([]),
count: z.number().catch(0),
total: z.number().catch(0),
total_est_plafonne: z.boolean().catch(false),
page: z.number().catch(1),
page_size: z.number().catch(0),
next_cursor: z.string().nullable().catch(null),
});

export type TendersPage = {
items: VigieProcureTender[];
count: number;
total: number;
totalEstPlafonne: boolean;
page: number;
pageSize: number;
nextCursor: string | null;
};

export type TendersQuery = {
cpv?: string;
department?: string;
q?: string;
page?: number;
limit?: number;
cursor?: string;
};

export type TendersResult =
| { outcome: "ok"; page: TendersPage }
| { outcome: "not-configured" }
| { outcome: "unauthorized"; reason: string }
| { outcome: "failed"; reason: string };

/**
* Liste les marches ouverts via l'API VigieProcure
* (`GET /api/v1/tenders?status=active`). `status` est TOUJOURS "active" --
* ce n'est pas un parametre d'entree cote CRM, c'est le seul statut que
* cette page a vocation a montrer (marches publies et non encore
* echus, cf. api_v2/routers/tenders.py::_STATUTS).
*
* Pas de champ "lien vers l'avis d'origine" ici : `external_url` n'existe
* QUE sur `GET /tenders/{id}` (extrait de `raw_data->>'url_avis'`), pas sur
* ce feed de liste. On ne l'invente pas.
*/
export async function listOpenTenders(
query: TendersQuery,
): Promise<TendersResult> {
const api = vigieProcureTendersApi();
if (!api) return { outcome: "not-configured" };

const target = new URL(api.url);
target.searchParams.set("status", "active");
// Omission volontaire des parametres vides : `q`/`cpv` exigent
// min_length>=2 et `department` min_length>=1 cote api_v2 -- envoyer une
// chaine vide donnerait un 422 sur CHAQUE chargement de page non filtre.
if (query.cpv) target.searchParams.set("cpv", query.cpv);
if (query.department) target.searchParams.set("department", query.department);
if (query.q) target.searchParams.set("q", query.q);
if (query.cursor) {
target.searchParams.set("cursor", query.cursor);
} else if (query.page) {
target.searchParams.set("page", String(query.page));
}
target.searchParams.set("limit", String(query.limit ?? 20));

try {
const response = await fetch(target, {
headers: { authorization: `Bearer ${api.jwt}` },
signal: AbortSignal.timeout(TENDERS_TIMEOUT_MS),
});

if (response.status === 401 || response.status === 403) {
return {
outcome: "unauthorized",
reason: `VigieProcure answered ${response.status} -- the service JWT may be missing or expired.`,
};
}

if (!response.ok) {
return {
outcome: "failed",
reason: `VigieProcure answered ${response.status}.`,
};
}

const parsed = tendersResponse.safeParse(await response.json());
if (!parsed.success) {
return {
outcome: "failed",
reason: "VigieProcure's response did not match the expected shape.",
};
}

return {
outcome: "ok",
page: {
items: parsed.data.items,
count: parsed.data.count,
total: parsed.data.total,
totalEstPlafonne: parsed.data.total_est_plafonne,
page: parsed.data.page,
pageSize: parsed.data.page_size,
nextCursor: parsed.data.next_cursor,
},
};
} catch (cause) {
const aborted = cause instanceof Error && cause.name === "AbortError";
return {
outcome: "failed",
reason: aborted
? `Timed out after ${TENDERS_TIMEOUT_MS}ms.`
: cause instanceof Error
? cause.message
: String(cause),
};
}
}
Loading