Skip to content

Commit a12df24

Browse files
authored
Merge pull request #1878 from rocket-admin/backend_visitor_table_actions
feat: implement row-event bridge for generated-site visitors and enhance table action handling
2 parents e09a2ef + 2bc983c commit a12df24

13 files changed

Lines changed: 578 additions & 11 deletions

File tree

‎backend/src/common/data-injection.tokens.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export enum UseCaseType {
199199
SITENOVA_LOGIN_ENDUSER = 'SITENOVA_LOGIN_ENDUSER',
200200
SITENOVA_GET_CONNECTION = 'SITENOVA_GET_CONNECTION',
201201
SITENOVA_VALIDATE_PUBLIC_READ = 'SITENOVA_VALIDATE_PUBLIC_READ',
202+
SITENOVA_ACTIVATE_ROW_EVENT_ACTIONS = 'SITENOVA_ACTIVATE_ROW_EVENT_ACTIONS',
202203

203204
CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS',
204205
FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS',

‎backend/src/entities/email/email-text/email-text.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { UserInfoMessageData } from '../../table-actions/table-actions-module/table-action-activation.service.js';
1+
import {
2+
describeTableActionActor,
3+
UserInfoMessageData,
4+
} from '../../table-actions/table-actions-module/utils/describe-table-action-actor.util.js';
25
import { escapeHtml } from '../utils/escape-html.util.js';
36

47
export const EMAIL_TEXT = {
@@ -71,8 +74,7 @@ export const EMAIL_TEXT = {
7174
tableName: string,
7275
primaryKeyValuesArray: Array<Record<string, unknown>>,
7376
): string => {
74-
const { email, userId, userName } = userInfo;
75-
const textContent = `${userName ? escapeHtml(userName) : 'User'} (email: ${email}, user id: ${userId}) has ${action} in the table "${escapeHtml(tableName)}".`;
77+
const textContent = `${describeTableActionActor(userInfo)} has ${action} in the table "${escapeHtml(tableName)}".`;
7678
return `${textContent} Primary Keys: ${JSON.stringify(primaryKeyValuesArray)}`;
7779
},
7880
},

‎backend/src/entities/table-actions/table-actions-module/table-action-activation.service.ts‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@ import { EmailService } from '../../email/email/email.service.js';
2121
import { escapeHtml } from '../../email/utils/escape-html.util.js';
2222
import { UserEntity } from '../../user/user.entity.js';
2323
import { TableActionEntity } from './table-action.entity.js';
24+
import { describeTableActionActor, UserInfoMessageData } from './utils/describe-table-action-actor.util.js';
25+
26+
export type { TableActionActorKind, UserInfoMessageData } from './utils/describe-table-action-actor.util.js';
2427

2528
export type ActionActivationResult = {
2629
location?: string;
2730
receivedOperationResult: OperationResultStatusEnum;
2831
receivedPrimaryKeysObj: Array<Record<string, unknown>>;
2932
};
3033

31-
export type UserInfoMessageData = {
32-
userId: string;
33-
email: string;
34-
userName: string | null;
34+
// A generated-site end-user acting through universal-backend (plan 37): the users-row primary key
35+
// carried by their token (`uid`, null for a pre-uid grace token) and the address they registered
36+
// with when the site passed it along. Neither is a RocketAdmin identity.
37+
export type SitenovaVisitorActor = {
38+
visitorId: string | null;
39+
email: string | null;
3540
};
3641

3742
@Injectable()
@@ -62,10 +67,59 @@ export class TableActionActivationService {
6267
);
6368
}
6469
const userInfoMessageData: UserInfoMessageData = {
70+
actorKind: 'rocketadmin_user',
6571
userId,
6672
email: foundUser.email,
6773
userName: foundUser.name,
74+
visitorId: null,
6875
};
76+
return await this.activateTableActionAs(
77+
tableAction,
78+
foundConnection,
79+
request_body,
80+
userInfoMessageData,
81+
tableName,
82+
triggerOperation,
83+
);
84+
}
85+
86+
// Same dispatch as activateTableAction, for an operation performed by a generated-site visitor
87+
// rather than a RocketAdmin user (plan 37). There is no account to look up: the actor is
88+
// described from what universal-backend forwarded, and the HTTP payload / Slack / email say so
89+
// explicitly instead of borrowing the connection owner's identity.
90+
public async activateTableActionForVisitor(
91+
tableAction: TableActionEntity,
92+
foundConnection: ConnectionEntity,
93+
request_body: Array<Record<string, unknown>>,
94+
visitor: SitenovaVisitorActor,
95+
tableName: string,
96+
triggerOperation: TableActionEventEnum,
97+
): Promise<ActionActivationResult> {
98+
const userInfoMessageData: UserInfoMessageData = {
99+
actorKind: 'sitenova_visitor',
100+
userId: null,
101+
email: visitor.email,
102+
userName: null,
103+
visitorId: visitor.visitorId,
104+
};
105+
return await this.activateTableActionAs(
106+
tableAction,
107+
foundConnection,
108+
request_body,
109+
userInfoMessageData,
110+
tableName,
111+
triggerOperation,
112+
);
113+
}
114+
115+
private async activateTableActionAs(
116+
tableAction: TableActionEntity,
117+
foundConnection: ConnectionEntity,
118+
request_body: Array<Record<string, unknown>>,
119+
userInfoMessageData: UserInfoMessageData,
120+
tableName: string,
121+
triggerOperation: TableActionEventEnum,
122+
): Promise<ActionActivationResult> {
69123
switch (tableAction.method) {
70124
case TableActionMethodEnum.URL:
71125
return await this.activateHttpTableAction(
@@ -190,8 +244,14 @@ export class TableActionActivationService {
190244
);
191245

192246
const dateString = new Date().toISOString();
247+
// Wire contract of URL actions (consumers verify it with the Rocketadmin-Signature HMAC).
248+
// $$_raUserId is the historical field and stays a string for RocketAdmin users; it is null when
249+
// a generated-site visitor acted (plan 37) — such events never fired before, so no existing
250+
// consumer saw a null there. $$_triggeredBy / $$_visitorId are additive.
193251
const actionRequestBody = JSON.stringify({
194252
$$_raUserId: userId,
253+
$$_triggeredBy: userInfo.actorKind,
254+
$$_visitorId: userInfo.visitorId,
195255
primaryKeys: primaryKeyValuesArray,
196256
$$_date: dateString,
197257
$$_actionId: tableAction.id,
@@ -380,7 +440,6 @@ export class TableActionActivationService {
380440
tableName: string,
381441
primaryKeyValuesArray: Array<Record<string, unknown>>,
382442
): string {
383-
const { email, userId, userName } = userInfo;
384443
const action =
385444
triggerOperation === TableActionEventEnum.ADD_ROW
386445
? 'added a row'
@@ -390,7 +449,7 @@ export class TableActionActivationService {
390449
? 'deleted a row'
391450
: 'performed an action';
392451
primaryKeyValuesArray = this.escapePrimaryKeyValuesArray(primaryKeyValuesArray);
393-
const textContent = `${userName ? escapeHtml(userName) : 'User'} (email: ${email}, user id: ${userId}) has ${action} in the table "${escapeHtml(tableName)}".`;
452+
const textContent = `${describeTableActionActor(userInfo)} has ${action} in the table "${escapeHtml(tableName)}".`;
394453
return `${textContent} Primary Keys: ${JSON.stringify(primaryKeyValuesArray)}`;
395454
}
396455
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { escapeHtml } from '../../../email/utils/escape-html.util.js';
2+
3+
// Who performed the operation a table action reacts to.
4+
// - 'rocketadmin_user': the historical case — a RocketAdmin account acting through the admin panel
5+
// (or the API), resolved from its user id.
6+
// - 'sitenova_visitor': an end-user of a generated SiteNova website whose write reached the core
7+
// through universal-backend's row-event bridge (POST /internal/sitenova/row-event, plan 37). Such
8+
// a visitor has NO RocketAdmin account: what is known is their users-row primary key (the token
9+
// `uid`) and, when the site passed it along, the address they registered with.
10+
export type TableActionActorKind = 'rocketadmin_user' | 'sitenova_visitor';
11+
12+
export type UserInfoMessageData = {
13+
actorKind: TableActionActorKind;
14+
// RocketAdmin user id; null for a site visitor.
15+
userId: string | null;
16+
email: string | null;
17+
userName: string | null;
18+
// The visitor's users-row primary key (token uid); null for a RocketAdmin user.
19+
visitorId: string | null;
20+
};
21+
22+
// One sentence subject for Slack and email notifications. The RocketAdmin-user wording is the
23+
// historical one (kept byte-for-byte — downstream Slack filters may match on it); the visitor
24+
// wording never claims a RocketAdmin identity for someone who has none.
25+
export function describeTableActionActor(userInfo: UserInfoMessageData): string {
26+
if (userInfo.actorKind === 'sitenova_visitor') {
27+
const details: Array<string> = [];
28+
if (userInfo.visitorId) {
29+
details.push(`visitor id: ${escapeHtml(userInfo.visitorId)}`);
30+
}
31+
if (userInfo.email) {
32+
details.push(`email: ${escapeHtml(userInfo.email)}`);
33+
}
34+
return details.length ? `Site visitor (${details.join(', ')})` : 'Site visitor';
35+
}
36+
const { email, userId, userName } = userInfo;
37+
return `${userName ? escapeHtml(userName) : 'User'} (email: ${email}, user id: ${userId})`;
38+
}

‎backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,23 @@ export class SitenovaPublicReadValidationRO {
8585
})
8686
readableColumns: Array<string> | null;
8787
}
88+
89+
export class SitenovaRowEventActivationRO {
90+
@ApiProperty({ description: 'Table action id.' })
91+
actionId: string;
92+
93+
@ApiProperty({ description: "Activation outcome: 'successfully' | 'unsuccessfully' | 'unknown'." })
94+
result: string;
95+
}
96+
97+
export class SitenovaRowEventRO {
98+
@ApiProperty({
99+
description:
100+
'How many table actions the connection owner configured for this table + event in RocketAdmin. ' +
101+
'0 means nothing ran — the event was received but no rule matched.',
102+
})
103+
actionsMatched: number;
104+
105+
@ApiProperty({ type: SitenovaRowEventActivationRO, isArray: true })
106+
activationResults: Array<SitenovaRowEventActivationRO>;
107+
}

‎backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,15 @@ export class SitenovaValidatePublicReadDs {
1515
tableName: string;
1616
columnNames: Array<string> | null;
1717
}
18+
19+
// Row-event bridge (plan 37).
20+
export class SitenovaRowEventDs {
21+
connectionId: string;
22+
tableName: string;
23+
event: 'ADD_ROW' | 'UPDATE_ROW' | 'DELETE_ROW';
24+
primaryKeys: Array<Record<string, unknown>>;
25+
visitor: {
26+
uid: string | null;
27+
email: string | null;
28+
};
29+
}

‎backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import {
4+
ArrayMaxSize,
5+
ArrayNotEmpty,
6+
IsArray,
7+
IsIn,
8+
IsNotEmpty,
9+
IsOptional,
10+
IsString,
11+
IsUUID,
12+
MaxLength,
13+
ValidateNested,
14+
} from 'class-validator';
315

416
export class SitenovaBaseDto {
517
@ApiProperty({ description: 'Id of the RocketAdmin user the operation is performed on behalf of.' })
@@ -43,3 +55,55 @@ export class SitenovaValidatePublicReadDto {
4355
@IsString({ each: true })
4456
columnNames?: Array<string>;
4557
}
58+
59+
// --- Row-event bridge (plan 37): universal-backend → core table actions ---------------------------
60+
61+
// Only the row events a generated-site visitor can cause. CUSTOM events are admin-panel buttons
62+
// that need a RocketAdmin user and a Cedar check — deliberately not reachable from here.
63+
export const SITENOVA_ROW_EVENTS = ['ADD_ROW', 'UPDATE_ROW', 'DELETE_ROW'] as const;
64+
export type SitenovaRowEventType = (typeof SITENOVA_ROW_EVENTS)[number];
65+
66+
export class SitenovaRowEventVisitorDto {
67+
@ApiPropertyOptional({
68+
nullable: true,
69+
description: 'The visitor users-row primary key (token uid). Null for a pre-uid grace token.',
70+
})
71+
@IsOptional()
72+
@IsString()
73+
@MaxLength(255)
74+
uid?: string | null;
75+
76+
@ApiPropertyOptional({ nullable: true, description: 'The address the visitor registered with, when known.' })
77+
@IsOptional()
78+
@IsString()
79+
@MaxLength(320)
80+
email?: string | null;
81+
}
82+
83+
export class SitenovaRowEventDto {
84+
@ApiProperty({ description: 'Table the visitor wrote.' })
85+
@IsString()
86+
@IsNotEmpty()
87+
@MaxLength(255)
88+
tableName: string;
89+
90+
@ApiProperty({ enum: SITENOVA_ROW_EVENTS, description: 'Which row event happened.' })
91+
@IsIn(SITENOVA_ROW_EVENTS)
92+
event: SitenovaRowEventType;
93+
94+
@ApiProperty({
95+
type: 'array',
96+
items: { type: 'object', additionalProperties: true },
97+
description: 'Primary-key objects of the affected rows (one per row). Non-key columns are ignored.',
98+
})
99+
@IsArray()
100+
@ArrayNotEmpty()
101+
@ArrayMaxSize(100)
102+
primaryKeys: Array<Record<string, unknown>>;
103+
104+
@ApiPropertyOptional({ type: SitenovaRowEventVisitorDto, description: 'Who performed the write.' })
105+
@IsOptional()
106+
@ValidateNested()
107+
@Type(() => SitenovaRowEventVisitorDto)
108+
visitor?: SitenovaRowEventVisitorDto;
109+
}

‎backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
1010
import {
1111
SitenovaConnectionForSiteRuntimeRO,
1212
SitenovaPublicReadValidationRO,
13+
SitenovaRowEventRO,
1314
} from './data-structures/sitenova-internal-responses.ds.js';
1415
import { SitenovaRawQueryResultRO } from './data-structures/sitenova-responses.ds.js';
15-
import { SitenovaExecuteRawQueryDto, SitenovaValidatePublicReadDto } from './dto/sitenova.dtos.js';
16+
import { SitenovaExecuteRawQueryDto, SitenovaRowEventDto, SitenovaValidatePublicReadDto } from './dto/sitenova.dtos.js';
1617
import {
18+
ISitenovaActivateRowEventActions,
1719
ISitenovaExecuteRawQuery,
1820
ISitenovaGetConnection,
1921
ISitenovaValidatePublicRead,
@@ -37,8 +39,39 @@ export class SitenovaInternalController {
3739
private readonly getConnectionUseCase: ISitenovaGetConnection,
3840
@Inject(UseCaseType.SITENOVA_VALIDATE_PUBLIC_READ)
3941
private readonly validatePublicReadUseCase: ISitenovaValidatePublicRead,
42+
@Inject(UseCaseType.SITENOVA_ACTIVATE_ROW_EVENT_ACTIONS)
43+
private readonly activateRowEventActionsUseCase: ISitenovaActivateRowEventActions,
4044
) {}
4145

46+
@ApiOperation({
47+
summary: 'Report a generated-site visitor row write so the table actions configured for it run.',
48+
description:
49+
'universal-backend reports EVERY visitor write (create/update/delete, registration, file upload) here; ' +
50+
'the core runs only the table actions the connection owner attached to this table + event in ' +
51+
'RocketAdmin (URL webhook / Slack / email) and answers how many matched. No rule ⇒ nothing runs. ' +
52+
'Per-action failures are reported in the body, never as an error status — the write already happened.',
53+
})
54+
@ApiResponse({ status: 200, type: SitenovaRowEventRO })
55+
@ApiBody({ type: SitenovaRowEventDto })
56+
@HttpCode(HttpStatus.OK)
57+
@Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST)
58+
@Post('/row-event/:connectionId')
59+
public async activateRowEventActions(
60+
@SlugUuid('connectionId') connectionId: string,
61+
@Body() body: SitenovaRowEventDto,
62+
): Promise<SitenovaRowEventRO> {
63+
return await this.activateRowEventActionsUseCase.execute(
64+
{
65+
connectionId,
66+
tableName: body.tableName,
67+
event: body.event,
68+
primaryKeys: body.primaryKeys,
69+
visitor: { uid: body.visitor?.uid ?? null, email: body.visitor?.email ?? null },
70+
},
71+
InTransactionEnum.OFF,
72+
);
73+
}
74+
4275
@ApiOperation({
4376
summary: 'Return decrypted connection credentials plus the end-user JWT signing key.',
4477
description:

‎backend/src/microservices/sitenova-microservice/sitenova.module.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { SitenovaPublicReadGuard } from './guards/sitenova-public-read.guard.js'
2828
import { SitenovaEndUserAuthService } from './services/sitenova-enduser-auth.service.js';
2929
import { SitenovaInternalController } from './sitenova-internal.controller.js';
3030
import { SitenovaSiteController } from './sitenova-site.controller.js';
31+
import { SitenovaActivateRowEventActionsUseCase } from './use-cases/sitenova-activate-row-event-actions.use.case.js';
3132
import { SitenovaExecuteRawQueryUseCase } from './use-cases/sitenova-execute-raw-query.use.case.js';
3233
import { SitenovaGetConnectionUseCase } from './use-cases/sitenova-get-connection.use.case.js';
3334
import { SitenovaLoginEndUserUseCase } from './use-cases/sitenova-login-enduser.use.case.js';
@@ -74,6 +75,10 @@ import { SitenovaValidatePublicReadUseCase } from './use-cases/sitenova-validate
7475
provide: UseCaseType.SITENOVA_VALIDATE_PUBLIC_READ,
7576
useClass: SitenovaValidatePublicReadUseCase,
7677
},
78+
{
79+
provide: UseCaseType.SITENOVA_ACTIVATE_ROW_EVENT_ACTIONS,
80+
useClass: SitenovaActivateRowEventActionsUseCase,
81+
},
7782
{
7883
provide: UseCaseType.SITENOVA_REGISTER_ENDUSER,
7984
useClass: SitenovaRegisterEndUserUseCase,

0 commit comments

Comments
 (0)