Skip to content
Open
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
5 changes: 3 additions & 2 deletions apps/ui-community/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-istanbul": "catalog:",
"autoprefixer": "^10.5.4",
"esbuild": "catalog:",
"jsdom": "^26.1.0",
"rollup-plugin-visualizer": "^6.0.5",
Expand All @@ -60,7 +61,7 @@
"tailwindcss": "^3.4.17",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vite-plugin-node-polyfills": "catalog:"
"vite-plugin-node-polyfills": "catalog:",
"vitest": "catalog:"
}
}
8 changes: 7 additions & 1 deletion apps/ui-staff/src/hooks/use-staff-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const CURRENT_STAFF_USER_QUERY = gql`
}
techAdminPermissions {
canManageTechAdmin
canViewQueues
canSendQueueMessages
}
}
}
Expand All @@ -49,6 +51,8 @@ interface StaffPermissions {
canViewStaffUsers: boolean;
canManageFinance: boolean;
canManageTechAdmin: boolean;
canViewQueues: boolean;
canSendQueueMessages: boolean;
canViewRoles: boolean;
canAddRole: boolean;
canEditRole: boolean;
Expand All @@ -72,7 +76,7 @@ interface StaffUserQueryResult {
userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canViewStaffUsers: boolean };
staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean };
financePermissions: { canManageFinance: boolean };
techAdminPermissions: { canManageTechAdmin: boolean };
techAdminPermissions: { canManageTechAdmin: boolean; canViewQueues: boolean; canSendQueueMessages: boolean };
};
};
};
Expand Down Expand Up @@ -104,6 +108,8 @@ export const useStaffPermissions = (): {
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin,
canManageTechAdmin: isTechAdmin,
canViewQueues: rolePermissions.techAdminPermissions.canViewQueues,
canSendQueueMessages: rolePermissions.techAdminPermissions.canSendQueueMessages,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
Expand Down
14 changes: 14 additions & 0 deletions codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ generates:
- typescript-operations
- typed-document-node

'./packages/ocom/ui-staff-route-tech-admin/src/generated.tsx':
documents:
- './packages/ocom/ui-staff-route-tech-admin/src/**/**.graphql'
config:
withHooks: true
withHOC: false
withComponent: false
useTypeImports: true
enumsAsTypes: true
plugins:
- typescript
- typescript-operations
- typed-document-node

# Cellix core base type defs (static array for rolldown bundling)
'./packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts':
plugins:
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
"@cellix/graphql-codegen",
"@graphql-typed-document-node/core",
"@vitest/coverage-v8",
"autoprefixer",
"ts-scope-trimmer-plugin",
"chrome-devtools-mcp"
],
Expand Down
44 changes: 44 additions & 0 deletions packages/cellix/service-queue-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,28 @@ await service.sendMessageToOrderCreatedQueue({
});
```

### Send to a registered queue selected at runtime

Operational workflows can send to the physical name of any queue registered as
either inbound or outbound. The service rejects unregistered names and validates
the payload against the selected queue's schema before enqueueing it.

```ts
await service.sendMessageToRegisteredQueue('import-requests', {
importId: 'import-123',
}, {
visibilityTimeoutSeconds: 30,
loggingDirection: 'inbound',
loggingTags: { source: 'operations' },
loggingMetadata: { reason: 'replay' },
});
```

This is intentionally narrower than the raw Azure transport. Prefer generated
`sendMessageTo...Queue` methods when the destination is known at compile time.
The operation accepts all `SendMessageOptions`; logging values from the selected
queue definition are defaults, and explicitly supplied options take precedence.

### Receive from an inbound queue

```ts
Expand All @@ -159,6 +181,27 @@ const message = await service.receiveFromImportRequestsQueue(queueItem, {
const messages = await service.peekAtImportRequestsQueue();
```

### Peek at a poison queue

Use the generated poison-queue method to inspect messages Azure Functions moved
after retry exhaustion. It is read-only and returns the same payload type as the
primary queue.

```ts
const messages = await service.peekAtImportRequestsPoisonQueue();
```

### Get an approximate queue message count

Use the generated count methods when an operational view needs the number of
messages currently reported by Azure Queue Storage. Counts are approximate and
include messages that are not presently visible.

```ts
const primaryCount = await service.getImportRequestsQueueMessageCount();
const poisonCount = await service.getImportRequestsPoisonQueueMessageCount();
```

## Queue Naming

Each queue has:
Expand Down Expand Up @@ -206,6 +249,7 @@ If you want to provision only a subset, pass `serviceDefaults.provisionQueues` t
- `createRegisteredQueueService`
- `QueueRegistryOperations`
- `QueueRegistryService`
- `RegisteredQueueSender`
- `QueueStorageConfig`
- `QueueLoggingConfig`
- `QueueTriggerMetadata`
Expand Down
1 change: 1 addition & 0 deletions packages/cellix/service-queue-storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
QueueRegistryService,
QueueServiceConstructorOptions,
RegisteredQueueRegistry,
RegisteredQueueSender,
RegisteredQueueService,
} from './register-queues.ts';
export { createRegisteredQueueService, deriveProvisionQueues, registerQueues } from './register-queues.ts';
7 changes: 7 additions & 0 deletions packages/cellix/service-queue-storage/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ export interface IQueueStorageOperations {
* @returns Decoded queue messages without altering visibility or dequeue state.
*/
peekMessages<_T = unknown>(queue: string, opts?: PeekMessagesOptions): Promise<QueueMessage<_T>[]>;
/**
* Reads Azure Queue Storage's approximate visible and invisible message count.
*
* @param queue - Physical Azure Queue Storage queue name.
* @returns The approximate number of messages currently in the queue.
*/
getApproximateMessageCount(queue: string): Promise<number>;
}

type QueueMessageSchema = Readonly<Record<string, unknown>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ describe('InternalQueueStorageService', () => {
expect(sendMessage).toHaveBeenCalledWith(expect.any(String), { visibilityTimeout: 45 });
});

it('returns Azure Queue Storage approximate message counts', async () => {
const getProperties = vi.fn(async () => ({ approximateMessagesCount: 12 }));
fromConnectionStringMock.mockImplementation((_conn: string) => ({
getQueueClient: vi.fn((_q: string) => ({
sendMessage: vi.fn(async (_m: string) => ({ messageId: 'mid' })),
createIfNotExists: vi.fn(async () => ({ succeeded: true })),
receiveMessages: vi.fn(async () => ({ receivedMessageItems: [] })),
peekMessages: vi.fn(async () => ({ peekedMessageItems: [] })),
deleteMessage: vi.fn(async () => ({})),
getProperties,
})),
}));
const svc = new InternalQueueStorageService({ connectionString: 'UseDevelopmentStorage=true' });
await svc.startUp();

await expect(svc.getApproximateMessageCount('q')).resolves.toBe(12);
expect(getProperties).toHaveBeenCalledOnce();
});

it('createQueueIfNotExists does not throw for missing queue', async () => {
const svc = new InternalQueueStorageService({ connectionString: 'UseDevelopmentStorage=true' });
await svc.startUp();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,12 @@ export class InternalQueueStorageService implements InternalQueueTransport {
}
return out;
}

/**
* Gets Azure Queue Storage's approximate number of messages in a queue.
*/
public async getApproximateMessageCount(queue: string): Promise<number> {
const properties = await this.getQueueClient(queue).getProperties();
return properties.approximateMessagesCount ?? 0;
}
}
21 changes: 21 additions & 0 deletions packages/cellix/service-queue-storage/src/queue-consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,25 @@ describe('registerQueues', () => {
},
]);
});

it('peeks at the inbound poison queue', async () => {
const registry = createInboundRegistry();
const svc = new registry.Service({ connectionString: 'UseDevelopmentStorage=true' });
peekedMessageItems = [
{
messageId: 'poison-msg-1',
messageText: Buffer.from(JSON.stringify({ requestId: 'r1' })).toString('base64'),
dequeueCount: 5,
},
];
await svc.startUp();

await expect(svc.peekAtImportRequestsPoisonQueue(8)).resolves.toEqual([
{
id: 'poison-msg-1',
payload: { requestId: 'r1' },
dequeueCount: 5,
},
]);
});
});
14 changes: 12 additions & 2 deletions packages/cellix/service-queue-storage/src/queue-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ type Capitalize<S extends string> = S extends `${infer F}${infer R}` ? `${Upperc
* Public consumer methods generated for an application's inbound queues.
*
* Each queue key becomes a strongly-typed `receiveFrom...Queue` method and a
* matching `peekAt...Queue` method on the registered service surface.
* matching `peekAt...Queue` and `get...QueueMessageCount` method on the
* registered service surface.
*
* @typeParam I - Inbound queue definition map passed to `registerQueues()`.
*
Expand All @@ -31,12 +32,18 @@ export type QueueConsumerContext<I extends QueueMap> = {
[K in keyof I as `receiveFrom${Capitalize<string & K>}Queue`]: (payload: unknown, metadata?: QueueTriggerMetadata) => Promise<QueueMessage<MessagePayload<I[K]>>>;
} & {
[K in keyof I as `peekAt${Capitalize<string & K>}Queue`]: (maxMessages?: number) => Promise<QueueMessage<MessagePayload<I[K]>>[]>;
} & {
[K in keyof I as `peekAt${Capitalize<string & K>}PoisonQueue`]: (maxMessages?: number) => Promise<QueueMessage<MessagePayload<I[K]>>[]>;
} & {
[K in keyof I as `get${Capitalize<string & K>}QueueMessageCount`]: () => Promise<number>;
} & {
[K in keyof I as `get${Capitalize<string & K>}PoisonQueueMessageCount`]: () => Promise<number>;
};

type QueueMessage<T> = { id: string; popReceipt?: string; payload: T; dequeueCount?: number };

export function createQueueConsumer<I extends QueueMap>(
service: Pick<InternalQueueTransport, 'peekMessages' | 'getLogger' | 'isLoggingEnabled' | 'shouldAwaitLogging'>,
service: Pick<InternalQueueTransport, 'peekMessages' | 'getApproximateMessageCount' | 'getLogger' | 'isLoggingEnabled' | 'shouldAwaitLogging'>,
definitions: I,
validators: Record<string, QueuePayloadValidator>,
): QueueConsumerContext<I> {
Expand Down Expand Up @@ -91,6 +98,9 @@ export function createQueueConsumer<I extends QueueMap>(
};

context[`peekAt${cap}Queue`] = (maxMessages?: number) => service.peekMessages(def.queueName, { maxMessages: maxMessages ?? 32 });
context[`peekAt${cap}PoisonQueue`] = (maxMessages?: number) => service.peekMessages(`${def.queueName}-poison`, { maxMessages: maxMessages ?? 32 });
context[`get${cap}QueueMessageCount`] = () => service.getApproximateMessageCount(def.queueName);
context[`get${cap}PoisonQueueMessageCount`] = () => service.getApproximateMessageCount(`${def.queueName}-poison`);
}

return context as QueueConsumerContext<I>;
Expand Down
Loading
Loading