snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'c832302c-e551-4b4f-b11c-19907888a284';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Media Notes',
description:
'Example app demonstrating the recordAudio / recordVideo front component capability',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,13 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import { MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../components/media-notes.front-component';
export default defineCommandMenuItem({
universalIdentifier: '83d9d1ba-b042-41c1-94e8-892931d8663f',
label: 'Record media note',
shortLabel: 'Media note',
icon: 'IconMicrophone',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,15 @@
export const MEDIA_NOTES_TEST_IDS = {
root: 'media-notes-root',
recordAudioButton: 'media-notes-record-audio',
recordVideoButton: 'media-notes-record-video',
recordingTimer: 'media-notes-recording-timer',
stopRecordingButton: 'media-notes-stop-recording',
cancelRecordingButton: 'media-notes-cancel-recording',
captureStatus: 'media-notes-capture-status',
capturedFile: 'media-notes-captured-file',
capturedAudio: 'media-notes-captured-audio',
capturedVideo: 'media-notes-captured-video',
savedRecord: 'media-notes-saved-record',
attachError: 'media-notes-attach-error',
retryAttachButton: 'media-notes-retry-attach',
};

View File

@@ -0,0 +1,649 @@
import { useEffect, useRef, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
type UploadedFrontComponentFile,
uploadFile,
} from 'twenty-sdk/front-component';
import { RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/media-note.object';
import { MEDIA_NOTES_TEST_IDS } from './media-notes-test-ids';
export const MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'14695714-35b5-4814-9489-f65c12368b5c';
const buttonStyle: React.CSSProperties = {
backgroundColor: '#1961ed',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
marginRight: '8px',
padding: '8px 12px',
};
const OBJECTS_PAGE_SIZE = 100;
const MAX_RECORDING_DURATION_SECONDS = 60;
type ObjectsPage = {
fields: { id: string; universalIdentifier: string | null }[];
nextCursor: string | null;
};
const fetchObjectsPage = async (after: string | null): Promise<ObjectsPage> => {
const result = await new MetadataApiClient().query({
objects: {
__args: {
paging: { first: OBJECTS_PAGE_SIZE, ...(after ? { after } : {}) },
filter: {},
},
pageInfo: { hasNextPage: true, endCursor: true },
edges: {
node: { fieldsList: { id: true, universalIdentifier: true } },
},
},
});
const pageInfo = result.objects.pageInfo;
return {
fields: result.objects.edges.flatMap((edge) => edge.node.fieldsList ?? []),
nextCursor:
pageInfo.hasNextPage === true ? (pageInfo.endCursor ?? null) : null,
};
};
// An app declares its fields by universalIdentifier; the per-instance
// fieldMetadataId the recording upload needs is resolved at runtime. The
// metadata API cannot filter on universalIdentifier, so page through the
// object list until the field turns up.
const fetchRecordingFieldMetadataId = async (): Promise<string | null> => {
let cursor: string | null = null;
for (;;) {
const page: ObjectsPage = await fetchObjectsPage(cursor);
const recordingField = page.fields.find(
(field) =>
field.universalIdentifier === RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
);
if (recordingField) {
return recordingField.id;
}
if (page.nextCursor === null) {
return null;
}
cursor = page.nextCursor;
}
};
const MIME_TYPE_TO_FILE_EXTENSION: Record<string, string> = {
'audio/webm': 'webm',
'video/webm': 'webm',
'audio/mp4': 'm4a',
'video/mp4': 'mp4',
'audio/ogg': 'ogg',
'video/ogg': 'ogg',
};
const getFileExtension = (mimeType: string): string =>
MIME_TYPE_TO_FILE_EXTENSION[mimeType.split(';')[0].trim().toLowerCase()] ??
'webm';
// The recording engine is the standard web platform: getUserMedia and
// MediaRecorder, polyfilled into the sandbox by the host. Errors surface as
// DOMException names exactly like in a regular page.
const mapMediaErrorNameToReason = (errorName: string): string => {
switch (errorName) {
case 'NotAllowedError':
case 'SecurityError':
return 'permission-denied';
case 'NotFoundError':
case 'OverconstrainedError':
return 'no-device';
case 'NotReadableError':
return 'busy';
case 'NotSupportedError':
return 'blocked';
default:
return 'unknown';
}
};
const mapMediaErrorToReason = (error: unknown): string =>
error instanceof Error ? mapMediaErrorNameToReason(error.name) : 'unknown';
type CapturedMediaFile = UploadedFrontComponentFile & {
durationSeconds: number;
};
type CaptureResult =
| { status: 'captured'; file: CapturedMediaFile }
| { status: 'cancelled' }
| { status: 'failed'; reason: string };
const describeResult = (result: CaptureResult): string =>
result.status === 'failed' ? `failed:${result.reason}` : result.status;
type PendingAttach = {
mediaType: 'audio' | 'video';
file: CapturedMediaFile;
};
type ActiveRecording = {
mediaType: 'audio' | 'video';
startedAt: number;
mediaStream: MediaStream;
mediaRecorder: MediaRecorder;
};
type RecordingSession = {
wasCancelled: boolean;
isUserStopping: boolean;
errorName: string | null;
collectRecordedBlob: Promise<Blob>;
};
const MediaNotes = () => {
const [recordingFieldMetadataId, setRecordingFieldMetadataId] = useState<
string | null
>(null);
const [captureResult, setCaptureResult] = useState<CaptureResult | null>(
null,
);
const [activeRecording, setActiveRecording] =
useState<ActiveRecording | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [savedRecordId, setSavedRecordId] = useState<string | null>(null);
// Set only when attaching failed, so it doubles as the retry payload.
const [failedAttach, setFailedAttach] = useState<PendingAttach | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
// Covers the stop-and-upload window: without it the record buttons come
// back while the previous recording is still uploading, and two flows race.
const [isStopping, setIsStopping] = useState(false);
const recordingSessionRef = useRef<RecordingSession | null>(null);
const stopAndSaveRef = useRef<(() => void) | null>(null);
// Synchronous guard: a second click during the getUserMedia permission
// prompt would otherwise start a second capture.
const isStartingRef = useRef(false);
useEffect(() => {
fetchRecordingFieldMetadataId()
.then(setRecordingFieldMetadataId)
.catch(() => setRecordingFieldMetadataId(null));
}, []);
// The recording UX is the app's own: this timer is rendered and styled
// here, and the app also owns its duration ceiling — the host imposes
// none.
useEffect(() => {
if (activeRecording === null) {
return;
}
setElapsedSeconds(0);
const elapsedInterval = setInterval(() => {
const nextElapsedSeconds = Math.floor(
(Date.now() - activeRecording.startedAt) / 1000,
);
setElapsedSeconds(nextElapsedSeconds);
if (nextElapsedSeconds >= MAX_RECORDING_DURATION_SECONDS) {
stopAndSaveRef.current?.();
}
}, 1000);
return () => clearInterval(elapsedInterval);
}, [activeRecording]);
// Attaching the uploaded file to a record is what makes it permanent: until
// then it is a temporary file owned by the FILES field, so a failure here
// has to be recoverable rather than silent.
const attachToNewMediaNote = async (pendingAttach: PendingAttach) => {
setFailedAttach(null);
setIsAttaching(true);
try {
const created = await new CoreApiClient().mutation({
createMediaNote: {
__args: {
data: {
title: `${pendingAttach.mediaType} note`,
recording: [
{
fileId: pendingAttach.file.fileId,
label: pendingAttach.file.path,
},
],
},
},
id: true,
},
});
setSavedRecordId(created.createMediaNote?.id ?? null);
} catch {
setFailedAttach(pendingAttach);
} finally {
setIsAttaching(false);
}
};
const handleStartRecording = async (mediaType: 'audio' | 'video') => {
// Starting a new recording mid-attach would let the in-flight attach
// settle against a recording the UI has already replaced, offering a
// retry for the wrong file.
if (
recordingFieldMetadataId === null ||
isAttaching ||
isStopping ||
isStartingRef.current ||
activeRecording !== null
) {
return;
}
isStartingRef.current = true;
setCaptureResult(null);
setSavedRecordId(null);
setFailedAttach(null);
let acquiredMediaStream: MediaStream | null = null;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia(
mediaType === 'audio' ? { audio: true } : { video: true, audio: true },
);
acquiredMediaStream = mediaStream;
const mediaRecorder = new MediaRecorder(mediaStream);
const recordedChunks: Blob[] = [];
const recordingSession: RecordingSession = {
wasCancelled: false,
isUserStopping: false,
errorName: null,
collectRecordedBlob: new Promise<Blob>((resolve) => {
mediaRecorder.onstop = () =>
resolve(
new Blob(recordedChunks, {
type:
mediaRecorder.mimeType ||
(mediaType === 'audio' ? 'audio/webm' : 'video/webm'),
}),
);
}),
};
mediaRecorder.ondataavailable = (event) => {
const dataEvent = event as Event & { data: Blob };
if (dataEvent.data.size > 0) {
recordedChunks.push(dataEvent.data);
}
};
mediaRecorder.onerror = (event) => {
const errorEvent = event as Event & { error?: Error };
recordingSession.errorName = errorEvent.error?.name ?? 'UnknownError';
};
// A stop the app did not ask for means the recorder failed to start on
// the host or died mid-recording (it fires error then stop, like the
// native API): release the devices and surface the failure.
mediaRecorder.addEventListener('stop', () => {
if (recordingSession.isUserStopping || recordingSession.wasCancelled) {
return;
}
recordingSession.wasCancelled = true;
recordingSessionRef.current = null;
for (const track of mediaStream.getTracks()) {
track.stop();
}
setActiveRecording(null);
setCaptureResult({
status: 'failed',
reason:
recordingSession.errorName === null
? 'unknown'
: mapMediaErrorNameToReason(recordingSession.errorName),
});
});
// The host indicator's stop button (or a revoked device) surfaces as
// standard track ended events; the app reacts by discarding, without
// any bespoke callback from the host.
for (const track of mediaStream.getTracks()) {
track.onended = () => {
if (recordingSession.wasCancelled) {
return;
}
recordingSession.wasCancelled = true;
recordingSessionRef.current = null;
// One dead track ends the whole take: stop the recorder and the
// remaining tracks so nothing keeps capturing behind the
// cancelled state.
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
for (const remainingTrack of mediaStream.getTracks()) {
if (remainingTrack.readyState === 'live') {
remainingTrack.stop();
}
}
setActiveRecording(null);
setCaptureResult({ status: 'cancelled' });
};
}
mediaRecorder.start();
recordingSessionRef.current = recordingSession;
setActiveRecording({
mediaType,
startedAt: Date.now(),
mediaStream,
mediaRecorder,
});
} catch (error) {
// The recorder can fail after the devices were granted; do not leave
// them capturing behind a failure message.
if (acquiredMediaStream !== null) {
for (const track of acquiredMediaStream.getTracks()) {
track.stop();
}
}
setCaptureResult({
status: 'failed',
reason: mapMediaErrorToReason(error),
});
} finally {
isStartingRef.current = false;
}
};
const handleStopRecording = async () => {
if (activeRecording === null) {
return;
}
const { mediaRecorder, mediaStream, mediaType, startedAt } =
activeRecording;
const recordingSession = recordingSessionRef.current;
if (recordingSession) {
recordingSession.isUserStopping = true;
}
setActiveRecording(null);
setIsStopping(true);
try {
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
const recordedBlob = recordingSession
? await recordingSession.collectRecordedBlob
: null;
for (const track of mediaStream.getTracks()) {
track.stop();
}
if (
recordedBlob === null ||
recordingSession === null ||
recordingSession.wasCancelled
) {
setCaptureResult({ status: 'cancelled' });
return;
}
// An errored recorder still resolves the stop, but with nothing worth
// uploading.
if (recordingSession.errorName !== null || recordedBlob.size === 0) {
setCaptureResult({
status: 'failed',
reason:
recordingSession.errorName === null
? 'unknown'
: mapMediaErrorNameToReason(recordingSession.errorName),
});
return;
}
recordingSessionRef.current = null;
const durationSeconds = Math.max(
1,
Math.round((Date.now() - startedAt) / 1000),
);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const uploadResult = await uploadFile(recordedBlob, {
fieldMetadataId: recordingFieldMetadataId ?? '',
fileName: `${mediaType}-recording-${timestamp}.${getFileExtension(recordedBlob.type)}`,
});
if (uploadResult.status === 'failed') {
setCaptureResult({ status: 'failed', reason: uploadResult.reason });
return;
}
const capturedFile: CapturedMediaFile = {
...uploadResult.file,
durationSeconds,
};
setCaptureResult({ status: 'captured', file: capturedFile });
await attachToNewMediaNote({ mediaType, file: capturedFile });
} finally {
setIsStopping(false);
}
};
stopAndSaveRef.current = handleStopRecording;
const handleCancelRecording = async () => {
if (activeRecording === null) {
return;
}
const { mediaRecorder, mediaStream } = activeRecording;
const recordingSession = recordingSessionRef.current;
if (recordingSession) {
recordingSession.wasCancelled = true;
}
recordingSessionRef.current = null;
setActiveRecording(null);
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
for (const track of mediaStream.getTracks()) {
track.stop();
}
setCaptureResult({ status: 'cancelled' });
};
return (
<div
data-testid={MEDIA_NOTES_TEST_IDS.root}
style={{ fontFamily: 'sans-serif', padding: '16px' }}
>
<h3 style={{ fontSize: '15px', margin: '0 0 12px' }}>Media notes</h3>
<p style={{ color: '#555', fontSize: '13px', margin: '0 0 16px' }}>
Record a note with your microphone or camera. The recording is stored in
Twenty and attached to a Media note record.
</p>
{activeRecording === null ? (
<div style={{ marginBottom: '16px' }}>
<button
data-testid={MEDIA_NOTES_TEST_IDS.recordAudioButton}
style={buttonStyle}
disabled={
recordingFieldMetadataId === null || isAttaching || isStopping
}
onClick={() => handleStartRecording('audio')}
>
Record a voice note
</button>
<button
data-testid={MEDIA_NOTES_TEST_IDS.recordVideoButton}
style={buttonStyle}
disabled={
recordingFieldMetadataId === null || isAttaching || isStopping
}
onClick={() => handleStartRecording('video')}
>
Record a video note
</button>
</div>
) : (
<div
style={{
alignItems: 'center',
display: 'flex',
gap: '8px',
marginBottom: '16px',
}}
>
<span
style={{
backgroundColor: '#b42318',
borderRadius: '50%',
display: 'inline-block',
height: '10px',
width: '10px',
}}
/>
<span
data-testid={MEDIA_NOTES_TEST_IDS.recordingTimer}
style={{ fontSize: '13px', fontVariantNumeric: 'tabular-nums' }}
>
Recording {activeRecording.mediaType}… {elapsedSeconds}s
</span>
<button
data-testid={MEDIA_NOTES_TEST_IDS.stopRecordingButton}
style={buttonStyle}
onClick={handleStopRecording}
>
Stop and save
</button>
<button
data-testid={MEDIA_NOTES_TEST_IDS.cancelRecordingButton}
style={{ ...buttonStyle, backgroundColor: '#666' }}
onClick={handleCancelRecording}
>
Cancel
</button>
</div>
)}
{captureResult !== null && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.captureStatus}
style={{ color: '#333', fontSize: '13px', marginBottom: '12px' }}
>
{describeResult(captureResult)}
</div>
)}
{captureResult?.status === 'captured' && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.capturedFile}
style={{
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '12px',
padding: '12px',
}}
>
<p style={{ margin: '0 0 8px' }}>
{captureResult.file.mimeType} · {captureResult.file.durationSeconds}
s · {Math.round(captureResult.file.size / 1024)} KB
</p>
{captureResult.file.mimeType.startsWith('audio/') ? (
<audio
data-testid={MEDIA_NOTES_TEST_IDS.capturedAudio}
controls
src={captureResult.file.url}
style={{ width: '100%' }}
/>
) : (
<video
data-testid={MEDIA_NOTES_TEST_IDS.capturedVideo}
controls
src={captureResult.file.url}
style={{ maxHeight: '200px', width: '100%' }}
/>
)}
{savedRecordId !== null && (
<p
data-testid={MEDIA_NOTES_TEST_IDS.savedRecord}
style={{ color: '#555', margin: '8px 0 0' }}
>
Attached to media note {savedRecordId}
</p>
)}
{failedAttach !== null && (
<div
data-testid={MEDIA_NOTES_TEST_IDS.attachError}
style={{ color: '#b42318', margin: '8px 0 0' }}
>
<p style={{ margin: '0 0 8px' }}>
The recording was uploaded but could not be attached to a media
note. It stays temporary until it is attached.
</p>
<button
data-testid={MEDIA_NOTES_TEST_IDS.retryAttachButton}
style={buttonStyle}
disabled={isAttaching}
onClick={() => attachToNewMediaNote(failedAttach)}
>
Retry attaching
</button>
</div>
)}
</div>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MEDIA_NOTES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'media-notes-component',
description: 'Record audio or video notes with standard web media APIs',
component: MediaNotes,
});

View File

@@ -0,0 +1,41 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
export const MEDIA_NOTE_UNIVERSAL_IDENTIFIER =
'68d3bcc0-1a6c-4e7d-9fc3-c7bc57b8241d';
export const TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'c1d4e7a2-3b56-4c89-9e12-4f6a8b0d3e75';
export const RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'd2e5f8b3-4c67-4d9a-8f23-5a7b9c1e4f86';
export default defineObject({
universalIdentifier: MEDIA_NOTE_UNIVERSAL_IDENTIFIER,
nameSingular: 'mediaNote',
namePlural: 'mediaNotes',
labelSingular: 'Media note',
labelPlural: 'Media notes',
description: 'A note captured with the microphone or camera',
icon: 'IconMicrophone',
labelIdentifierFieldMetadataUniversalIdentifier:
TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: TITLE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
label: 'Title',
description: "The media note's title",
icon: 'IconAbc',
name: 'title',
},
{
universalIdentifier: RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
label: 'Recording',
description: 'The captured audio or video note',
icon: 'IconMicrophone',
name: 'recording',
universalSettings: { maxNumberOfValues: 10 },
},
],
});

View File

@@ -0,0 +1,28 @@
import { defineRole } from 'twenty-sdk/define';
import { MEDIA_NOTE_UNIVERSAL_IDENTIFIER } from '../objects/media-note.object';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'04981304-eca8-4757-9e9a-254390eafc79';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Media notes default role',
description: 'Default role for the media notes example app',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: MEDIA_NOTE_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
});