feat(18-01): implement groupReportIntoCampaign tiered matching

- Tiered find-or-create inside postgresClient.transaction (Pitfall 2 —
  campaigns.campaign_key has no UNIQUE constraint): Tier 1 Message-ID,
  Tier 2 attachment-hash/URL-domain + subject + sender + 24h, Tier 3
  sender + normalized subject + client + 24h (CAMP-01)
- Match path bumps report_count/last_seen_at and links reports.campaign_id
  without creating a second campaign; no-match path inserts a new
  campaigns row keyed by the strongest available tier signal (CAMP-02)
- skipIfAlreadyGrouped short-circuits before the transaction (D-08); the
  /analyze route path always re-runs full tiered matching
- Every tier query excludes the report's own id (r.id != $n) so a
  self-match against a report's own messages/indicators can never
  double-increment its already-linked campaign on re-run
- D-07 doc comment states the Tier-3-only automatic-path limitation:
  parseAndStoreMessage is not wired into the webhook/cron path this phase
This commit is contained in:
lorentz 2026-07-15 19:22:31 -04:00
parent ea677b7aba
commit da926bbe97
2 changed files with 625 additions and 2 deletions

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock postgresClient BEFORE importing the module under test.
const queryMock = vi.fn();
@ -11,7 +11,7 @@ vi.mock('./postgres-client', () => ({
}));
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { normalizeSubject, extractUrlDomain } from './campaign-grouping-service';
import { normalizeSubject, extractUrlDomain, groupReportIntoCampaign } from './campaign-grouping-service';
describe('normalizeSubject', () => {
it('strips a single Re: prefix, lowercases, trims', () => {
@ -48,3 +48,289 @@ describe('extractUrlDomain', () => {
expect(extractUrlDomain('http://another.example.net')).toBe('another.example.net');
});
});
// =============================================================================
// groupReportIntoCampaign — mocked-DB behavior (CAMP-01, CAMP-02, D-08)
//
// `transactionMock` invokes its callback with a fake `client` whose `query`
// method routes to staged rows based on a distinguishing SQL substring for
// each of the implementation's queries (own report / own message / tier 1 /
// own indicators / tier 2 candidates / candidate indicators / tier 3 /
// update campaigns / update reports / insert campaign). This mirrors
// phishing-eml-service.test.ts's `callsContaining()` discipline but resolves
// per-call instead of asserting only after the fact, since groupReportInto
// Campaign's control flow branches on intermediate query results.
// =============================================================================
interface MockRows {
ownReport?: unknown[];
ownMessage?: unknown[];
tier1?: unknown[];
ownIndicators?: unknown[];
tier2Candidates?: unknown[];
candidateIndicators?: unknown[];
tier3?: unknown[];
insertCampaign?: unknown[];
}
/** Records every SQL call made through the fake transaction client. */
let clientCalls: Array<{ sql: string; params: unknown[] }> = [];
function makeClient(rows: MockRows) {
return {
query: vi.fn(async (sql: string, params?: unknown[]) => {
clientCalls.push({ sql, params: params ?? [] });
if (sql.includes('requester_contact_id, company_id, created_at')) {
return { rows: rows.ownReport ?? [], rowCount: rows.ownReport?.length ?? 0 };
}
if (sql.includes('FROM messages') && sql.includes('WHERE report_id = $1')) {
return { rows: rows.ownMessage ?? [], rowCount: rows.ownMessage?.length ?? 0 };
}
if (sql.includes('WHERE m.message_id = $1')) {
return { rows: rows.tier1 ?? [], rowCount: rows.tier1?.length ?? 0 };
}
if (sql.includes('FROM indicators') && sql.includes('WHERE message_id = $1')) {
return { rows: rows.ownIndicators ?? [], rowCount: rows.ownIndicators?.length ?? 0 };
}
if (sql.includes('BETWEEN $2::timestamptz')) {
return { rows: rows.tier2Candidates ?? [], rowCount: rows.tier2Candidates?.length ?? 0 };
}
if (sql.includes('message_id = ANY')) {
return { rows: rows.candidateIndicators ?? [], rowCount: rows.candidateIndicators?.length ?? 0 };
}
if (sql.includes('BETWEEN $4::timestamptz')) {
return { rows: rows.tier3 ?? [], rowCount: rows.tier3?.length ?? 0 };
}
if (sql.includes('UPDATE campaigns')) {
return { rows: [], rowCount: 1 };
}
if (sql.includes('UPDATE reports SET campaign_id')) {
return { rows: [], rowCount: 1 };
}
if (sql.includes('INSERT INTO campaigns')) {
return { rows: rows.insertCampaign ?? [{ id: 'unstaged-campaign-id' }], rowCount: 1 };
}
throw new Error(`Unstaged query in test mock: ${sql}`);
}),
};
}
function callsContaining(needle: string) {
return clientCalls.filter((c) => c.sql.includes(needle));
}
describe('groupReportIntoCampaign', () => {
beforeEach(() => {
queryMock.mockReset();
transactionMock.mockReset();
clientCalls = [];
});
function stage(rows: MockRows) {
transactionMock.mockImplementation(async (callback: (client: unknown) => Promise<unknown>) =>
callback(makeClient(rows))
);
}
const REPORT_ROW = {
title: 'Re: Invoice Alert',
requester_contact_id: 5,
company_id: 10,
created_at: '2026-07-15T10:00:00Z',
};
it('Tier 3 match increments existing campaign report_count, updates last_seen_at, links campaign_id, and never creates a second campaign', async () => {
stage({
ownReport: [REPORT_ROW],
ownMessage: [],
tier3: [{ campaign_id: 'campaign-1', title: 'Invoice Alert' }],
});
const result = await groupReportIntoCampaign('report-2');
expect(result).toEqual({
campaignId: 'campaign-1',
groupMethod: 'sender_subject_client',
created: false,
});
const updateCampaignCalls = callsContaining('UPDATE campaigns');
expect(updateCampaignCalls).toHaveLength(1);
expect(updateCampaignCalls[0].sql).toContain('report_count = report_count + 1');
expect(updateCampaignCalls[0].sql).toContain('last_seen_at = NOW()');
expect(updateCampaignCalls[0].params).toEqual(['campaign-1']);
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
expect(updateReportCalls).toHaveLength(1);
expect(updateReportCalls[0].params).toEqual(['campaign-1', 'report-2']);
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
});
it('creates exactly one new campaign when no tier matches anything', async () => {
stage({
ownReport: [REPORT_ROW],
ownMessage: [],
tier3: [],
insertCampaign: [{ id: 'new-campaign-id' }],
});
const result = await groupReportIntoCampaign('report-3');
expect(result).toEqual({
campaignId: 'new-campaign-id',
groupMethod: 'sender_subject_client',
created: true,
});
const insertCalls = callsContaining('INSERT INTO campaigns');
expect(insertCalls).toHaveLength(1);
expect(insertCalls[0].params).toEqual([
'sender_subject_client:5:invoice alert:10',
'sender_subject_client',
]);
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
});
it('skipIfAlreadyGrouped:true returns null when the pre-check SELECT reports a non-null campaign_id (D-08)', async () => {
queryMock.mockResolvedValueOnce({ rows: [{ campaign_id: 'already-grouped' }], rowCount: 1 });
const result = await groupReportIntoCampaign('report-4', { skipIfAlreadyGrouped: true });
expect(result).toBeNull();
expect(transactionMock).not.toHaveBeenCalled();
});
it('skipIfAlreadyGrouped:true proceeds to full matching when campaign_id is null', async () => {
queryMock.mockResolvedValueOnce({ rows: [{ campaign_id: null }], rowCount: 1 });
stage({
ownReport: [REPORT_ROW],
ownMessage: [],
tier3: [{ campaign_id: 'campaign-9', title: 'Invoice Alert' }],
});
const result = await groupReportIntoCampaign('report-5', { skipIfAlreadyGrouped: true });
expect(result).toEqual({
campaignId: 'campaign-9',
groupMethod: 'sender_subject_client',
created: false,
});
});
it('Tier 1 (Message-ID) matches before Tier 2/3 are ever queried', async () => {
stage({
ownReport: [REPORT_ROW],
ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-123' }],
tier1: [{ campaign_id: 'campaign-t1' }],
});
const result = await groupReportIntoCampaign('report-6');
expect(result).toEqual({
campaignId: 'campaign-t1',
groupMethod: 'message_id',
created: false,
});
expect(callsContaining('FROM indicators')).toHaveLength(0);
expect(callsContaining('JOIN contacts c')).toHaveLength(0);
});
it('Tier 2 (attachment-hash/URL-domain + subject + sender + 24h) matches when Tier 1 finds nothing', async () => {
stage({
ownReport: [REPORT_ROW],
ownMessage: [{ id: 'own-msg-id', message_id: 'unique-message-id-no-other-match' }],
tier1: [], // own message_id matches no other already-grouped report
ownIndicators: [
{ indicator_type: 'attachment_hash', value: 'hash123' },
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
],
tier2Candidates: [
{
report_id: 'other-report',
campaign_id: 'campaign-t2',
title: 'Invoice Alert',
message_id: 'other-msg-id',
},
],
candidateIndicators: [
{ message_id: 'other-msg-id', indicator_type: 'attachment_hash', value: 'hash123' },
{ message_id: 'other-msg-id', indicator_type: 'sender', value: 'attacker@evil.example.com' },
],
});
const result = await groupReportIntoCampaign('report-7');
expect(result).toEqual({
campaignId: 'campaign-t2',
groupMethod: 'attachment_or_url',
created: false,
});
});
it('extracts URL-domain (not raw URL string) when matching Tier 2 via url indicators', async () => {
stage({
ownReport: [REPORT_ROW],
ownMessage: [{ id: 'own-msg-id', message_id: 'unique-message-id-no-other-match-2' }],
tier1: [],
ownIndicators: [
{ indicator_type: 'url', value: 'https://evil.example.com/phish?x=1' },
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
],
tier2Candidates: [
{
report_id: 'other-report',
campaign_id: 'campaign-t2-url',
title: 'Invoice Alert',
message_id: 'other-msg-id-2',
},
],
candidateIndicators: [
// Different raw URL string, same domain — must match via extractUrlDomain, not exact string equality.
{ message_id: 'other-msg-id-2', indicator_type: 'url', value: 'https://evil.example.com/different-path' },
{ message_id: 'other-msg-id-2', indicator_type: 'sender', value: 'attacker@evil.example.com' },
],
});
const result = await groupReportIntoCampaign('report-8');
expect(result).toEqual({
campaignId: 'campaign-t2-url',
groupMethod: 'attachment_or_url',
created: false,
});
});
it('re-running on an already-grouped report whose own messages/indicators would otherwise self-match does not increment report_count a second time (self-exclusion)', async () => {
// Own message_id and own indicators exist (this report has already been
// through parseAndStoreMessage), but NO other report shares them — the
// only thing that *could* match is this report's own row, which every
// tier query excludes via `r.id != <this report's id>`.
stage({
ownReport: [REPORT_ROW],
ownMessage: [{ id: 'own-msg-id', message_id: 'self-message-id' }],
tier1: [], // self-exclusion means the report's own linked campaign never surfaces here
ownIndicators: [
{ indicator_type: 'attachment_hash', value: 'self-hash' },
{ indicator_type: 'sender', value: 'attacker@evil.example.com' },
],
tier2Candidates: [], // no other report in the 24h window
tier3: [], // no other report matches sender+subject+client either
insertCampaign: [{ id: 'fresh-campaign-id' }],
});
const result = await groupReportIntoCampaign('already-grouped-report');
// Tier 1's own message_id exists, so the new campaign is created with
// the strongest available key (message_id) — see computeTier1Key.
expect(result).toEqual({
campaignId: 'fresh-campaign-id',
groupMethod: 'message_id',
created: true,
});
// The critical assertion: no existing campaign's report_count is bumped
// a second time for this report re-run.
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
});
});

View file

@ -50,3 +50,340 @@ export function extractUrlDomain(url: string): string | null {
return null;
}
}
// =============================================================================
// Orchestration — groupReportIntoCampaign (mirrors detectPhishingTicket)
// =============================================================================
export interface GroupReportResult {
campaignId: string;
groupMethod: 'message_id' | 'attachment_or_url' | 'sender_subject_client';
created: boolean;
}
interface OwnReportRow {
title: string | null;
requester_contact_id: number | null;
company_id: number | null;
created_at: string;
}
interface OwnMessageRow {
id: string;
message_id: string | null;
}
interface IndicatorRow {
indicator_type: string;
value: string;
}
interface CandidateIndicatorRow extends IndicatorRow {
message_id: string;
}
interface Tier2CandidateRow {
report_id: string;
campaign_id: string;
title: string | null;
message_id: string;
}
/** Builds a `message_id:...` key — the strongest available signal (Tier 1). */
function computeTier1Key(messageId: string | null): string | null {
return messageId ? `message_id:${messageId}` : null;
}
/** Builds an `attachment_or_url:...` key from own indicators + subject + sender (Tier 2). */
function computeTier2Key(
attachmentHashes: string[],
urlDomains: string[],
normalizedSubject: string,
senderValue: string | null
): string | null {
if (!senderValue || !normalizedSubject) return null;
if (attachmentHashes.length === 0 && urlDomains.length === 0) return null;
const keyParts = [...attachmentHashes, ...urlDomains].sort();
return `attachment_or_url:${keyParts.join(',')}:${normalizedSubject}:${senderValue}`;
}
/** Builds a `sender_subject_client:...` key from sender + subject + company (Tier 3). */
function computeTier3Key(
requesterContactId: number | null,
normalizedSubject: string,
companyId: number | null
): string | null {
if (!requesterContactId || !normalizedSubject || !companyId) return null;
return `sender_subject_client:${requesterContactId}:${normalizedSubject}:${companyId}`;
}
/**
* Shared entry point called by the webhook path, the cron sweep, and the
* on-demand `/analyze` route. Looks up the report's own signal (message,
* indicators, requester/company), tries Tier 1 -> Tier 2 -> Tier 3 matching
* queries in order inside a single transaction (Pitfall 2 `campaigns.
* campaign_key` has no UNIQUE constraint), and either links the report to an
* existing campaign (bumping report_count/last_seen_at, CAMP-02) or inserts a
* new campaigns row.
*
* D-08: `opts.skipIfAlreadyGrouped` short-circuits (returns null) when the
* report is already linked used by the automatic webhook/cron path to
* avoid redundant work on every re-fire/sweep pass. The `/analyze` route
* omits this option so it always re-runs full tiered matching (it may
* upgrade a Tier-3-only report to Tier 1 after `parseAndStoreMessage` has
* just populated `messages`/`indicators` for the first time).
*
* D-04: never merges campaigns attaches to the first/best tiered match
* found, or creates a new campaign. Multi-campaign-merge logic is explicitly
* out of scope this phase.
*/
export async function groupReportIntoCampaign(
reportId: string,
opts?: { skipIfAlreadyGrouped?: boolean }
): Promise<GroupReportResult | null> {
try {
if (opts?.skipIfAlreadyGrouped) {
const existing = await postgresClient.query<{ campaign_id: string | null }>(
`SELECT campaign_id::text AS campaign_id FROM reports WHERE id = $1`,
[reportId]
);
if (existing.rows[0]?.campaign_id) {
return null; // already grouped, short-circuit (D-08)
}
}
return await postgresClient.transaction(async (client) => {
const ownReportRes = await client.query<OwnReportRow>(
`SELECT title, requester_contact_id, company_id, created_at
FROM reports
WHERE id = $1`,
[reportId]
);
const ownReport = ownReportRes.rows[0];
if (!ownReport) {
throw new Error(`groupReportIntoCampaign: report ${reportId} not found`);
}
const normalizedSubject = normalizeSubject(ownReport.title);
// Own messages row, if `parseAndStoreMessage` has already run for this
// report (D-07 — not the case for the automatic webhook/cron path
// until an explicit /analyze call has happened at least once).
const ownMessageRes = await client.query<OwnMessageRow>(
`SELECT id::text AS id, message_id
FROM messages
WHERE report_id = $1
LIMIT 1`,
[reportId]
);
const ownMessage = ownMessageRes.rows[0] ?? null;
let matchCampaignId: string | null = null;
let matchGroupMethod: GroupReportResult['groupMethod'] | null = null;
// ---------------------------------------------------------------------
// Tier 1: Message-ID. Self-exclusion (`r.id != $2`) is REQUIRED — without
// it this query would trivially match the report's own messages row
// against itself, double-incrementing its own already-linked campaign
// on every re-run (plan-checker finding).
// ---------------------------------------------------------------------
if (ownMessage?.message_id) {
const tier1 = await client.query<{ campaign_id: string }>(
`SELECT r.campaign_id::text AS campaign_id
FROM messages m
JOIN reports r ON r.id = m.report_id
WHERE m.message_id = $1
AND r.campaign_id IS NOT NULL
AND r.id != $2
ORDER BY r.created_at ASC
LIMIT 1`,
[ownMessage.message_id, reportId]
);
if (tier1.rows[0]?.campaign_id) {
matchCampaignId = tier1.rows[0].campaign_id;
matchGroupMethod = 'message_id';
}
}
// ---------------------------------------------------------------------
// Tier 2: attachment-hash / URL-domain + normalized subject + sender,
// within a 24h window (D-02). Only reachable if the own report has its
// own indicators (i.e. parseAndStoreMessage already ran for it).
// Self-exclusion (`r.id != $1`) required for the same reason as Tier 1.
// ---------------------------------------------------------------------
let ownAttachmentHashes: string[] = [];
let ownUrlDomains: string[] = [];
let ownSenderValue: string | null = null;
if (!matchCampaignId && ownMessage) {
const ownIndicatorsRes = await client.query<IndicatorRow>(
`SELECT indicator_type, value
FROM indicators
WHERE message_id = $1`,
[ownMessage.id]
);
ownAttachmentHashes = ownIndicatorsRes.rows
.filter((i) => i.indicator_type === 'attachment_hash')
.map((i) => i.value);
ownUrlDomains = ownIndicatorsRes.rows
.filter((i) => i.indicator_type === 'url')
.map((i) => extractUrlDomain(i.value))
.filter((d): d is string => d !== null);
ownSenderValue =
ownIndicatorsRes.rows.find((i) => i.indicator_type === 'sender')?.value ?? null;
if (
(ownAttachmentHashes.length > 0 || ownUrlDomains.length > 0) &&
ownSenderValue &&
normalizedSubject
) {
const tier2Candidates = await client.query<Tier2CandidateRow>(
`SELECT r.id::text AS report_id, r.campaign_id::text AS campaign_id, r.title,
m.id::text AS message_id
FROM messages m
JOIN reports r ON r.id = m.report_id
WHERE r.campaign_id IS NOT NULL
AND r.id != $1
AND r.created_at BETWEEN $2::timestamptz - INTERVAL '24 hours'
AND $2::timestamptz + INTERVAL '24 hours'`,
[reportId, ownReport.created_at]
);
if (tier2Candidates.rows.length > 0) {
const candidateMessageIds = tier2Candidates.rows.map((c) => c.message_id);
const candidateIndicatorsRes = await client.query<CandidateIndicatorRow>(
`SELECT message_id::text AS message_id, indicator_type, value
FROM indicators
WHERE message_id = ANY($1::uuid[])`,
[candidateMessageIds]
);
const indicatorsByMessage = new Map<string, CandidateIndicatorRow[]>();
for (const ind of candidateIndicatorsRes.rows) {
const arr = indicatorsByMessage.get(ind.message_id) ?? [];
arr.push(ind);
indicatorsByMessage.set(ind.message_id, arr);
}
for (const candidate of tier2Candidates.rows) {
if (normalizeSubject(candidate.title) !== normalizedSubject) continue;
const candidateIndicators = indicatorsByMessage.get(candidate.message_id) ?? [];
const candidateSender = candidateIndicators.find(
(i) => i.indicator_type === 'sender'
)?.value;
if (candidateSender !== ownSenderValue) continue;
const candidateHashes = candidateIndicators
.filter((i) => i.indicator_type === 'attachment_hash')
.map((i) => i.value);
const candidateDomains = candidateIndicators
.filter((i) => i.indicator_type === 'url')
.map((i) => extractUrlDomain(i.value))
.filter((d): d is string => d !== null);
const hashMatch = ownAttachmentHashes.some((h) => candidateHashes.includes(h));
const domainMatch = ownUrlDomains.some((d) => candidateDomains.includes(d));
if (hashMatch || domainMatch) {
matchCampaignId = candidate.campaign_id;
matchGroupMethod = 'attachment_or_url';
break;
}
}
}
}
}
// ---------------------------------------------------------------------
// Tier 3: sender + normalizeSubject(title) + client + 24h window
// (D-02). Joins reports.requester_contact_id -> contacts (Pitfall 5 —
// NOT `contact_id`). Self-exclusion (`r.id != $3`) required for the
// same reason as Tiers 1-2.
// ---------------------------------------------------------------------
if (!matchCampaignId && ownReport.requester_contact_id && ownReport.company_id && normalizedSubject) {
const tier3 = await client.query<{ campaign_id: string; title: string | null }>(
`SELECT r.campaign_id::text AS campaign_id, r.title
FROM reports r
JOIN contacts c ON c.id = r.requester_contact_id
WHERE r.requester_contact_id = $1
AND r.company_id = $2
AND r.campaign_id IS NOT NULL
AND r.id != $3
AND r.created_at BETWEEN $4::timestamptz - INTERVAL '24 hours'
AND $4::timestamptz + INTERVAL '24 hours'
ORDER BY r.created_at ASC`,
[ownReport.requester_contact_id, ownReport.company_id, reportId, ownReport.created_at]
);
const match = tier3.rows.find((r) => normalizeSubject(r.title) === normalizedSubject);
if (match) {
matchCampaignId = match.campaign_id;
matchGroupMethod = 'sender_subject_client';
}
}
// ---------------------------------------------------------------------
// Find-or-create against `campaigns` (CAMP-02).
// ---------------------------------------------------------------------
if (matchCampaignId && matchGroupMethod) {
await client.query(
`UPDATE campaigns
SET report_count = report_count + 1, last_seen_at = NOW(), updated_at = NOW()
WHERE id = $1`,
[matchCampaignId]
);
await client.query(
`UPDATE reports SET campaign_id = $1, updated_at = NOW() WHERE id = $2`,
[matchCampaignId, reportId]
);
return { campaignId: matchCampaignId, groupMethod: matchGroupMethod, created: false };
}
// No match on any tier — create a new campaign. Prefer the strongest
// available key (Tier 1 > Tier 2 > Tier 3) so future duplicates of THIS
// report can match it; do NOT set/transition campaigns.status (leave
// migration default 'open' — Phase 19/20's concern).
const tier1Key = computeTier1Key(ownMessage?.message_id ?? null);
const tier2Key = computeTier2Key(
ownAttachmentHashes,
ownUrlDomains,
normalizedSubject,
ownSenderValue
);
const tier3Key = computeTier3Key(
ownReport.requester_contact_id,
normalizedSubject,
ownReport.company_id
);
let newCampaignKey: string;
let newGroupMethod: GroupReportResult['groupMethod'];
if (tier1Key) {
newCampaignKey = tier1Key;
newGroupMethod = 'message_id';
} else if (tier2Key) {
newCampaignKey = tier2Key;
newGroupMethod = 'attachment_or_url';
} else if (tier3Key) {
newCampaignKey = tier3Key;
newGroupMethod = 'sender_subject_client';
} else {
newCampaignKey = `report:${reportId}`;
newGroupMethod = 'sender_subject_client';
}
const newCampaign = await client.query<{ id: string }>(
`INSERT INTO campaigns (campaign_key, group_method, first_seen_at, last_seen_at, report_count)
VALUES ($1, $2, NOW(), NOW(), 1)
RETURNING id::text AS id`,
[newCampaignKey, newGroupMethod]
);
const newCampaignId = newCampaign.rows[0].id;
await client.query(
`UPDATE reports SET campaign_id = $1, updated_at = NOW() WHERE id = $2`,
[newCampaignId, reportId]
);
return { campaignId: newCampaignId, groupMethod: newGroupMethod, created: true };
});
} catch (error) {
console.error('[CAMPAIGN-GROUPING] Failed to group report into campaign', reportId, error);
throw error;
}
}