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
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 MICSS Lab, University of Antwerp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ You can:
- Build code generation projects with templates
- Manage access with roles and resource sharing

SpatialDSL Studio is Sirius-inspired, not currently file-compatible with Sirius Desktop projects. Ecore/XMI semantic interchange is supported with limits; Sirius `.odesign` and `.aird` import/export are not implemented yet. See [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md).
SpatialDSL Studio is Sirius-inspired, not a drop-in replacement for Sirius Desktop projects. Ecore/XMI semantic interchange is supported with limits; an initial `.odesign` validate/import/export subset covers basic diagram descriptions and returns compatibility reports, and an initial `.aird` import subset turns Sirius diagram representations into SpatialDSL views (`.aird` export is not implemented). See [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md).

## Multi-Layer Architecture

Expand Down Expand Up @@ -75,7 +75,7 @@ The tool follows a four-layer architecture:
docker compose up --build
```

The Docker backend applies Prisma migrations before it starts. Development Docker also runs the seed script by default; set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the root `.env` if you want that seed to create or promote an admin account.
The Docker backend applies Prisma migrations before it starts. Development Docker also runs the seed script by default; set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the root `.env` if you want that seed to create or promote an admin account. The seed can also create a pre-verified demo account (no email verification step) via `DEMO_EMAIL` and `DEMO_PASSWORD`, with an optional `DEMO_ROLE` (defaults to `DSL_DESIGNER`). Seeded accounts are marked email-verified, so they work without a reachable mailbox; this is the intended path for reviewers and offline demos.

Then open:

Expand Down Expand Up @@ -128,3 +128,7 @@ For complete setup requirements (database, env vars, migrations), see the gettin
- [Data Model](docs/reference/data-model.md)
- [Architecture](docs/reference/architecture.md)
- [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md)

## License

This project is licensed under the MIT License, see [LICENSE](LICENSE).
55 changes: 40 additions & 15 deletions backend/prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,70 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';

const prisma = new PrismaClient();

const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;

async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.log('ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin seed.');
return;
}
const DEMO_EMAIL = process.env.DEMO_EMAIL;
const DEMO_PASSWORD = process.env.DEMO_PASSWORD;
const DEMO_ROLE_RAW = process.env.DEMO_ROLE;

const DEMO_ROLE: UserRole =
DEMO_ROLE_RAW && Object.values(UserRole).includes(DEMO_ROLE_RAW as UserRole)
? (DEMO_ROLE_RAW as UserRole)
: UserRole.DSL_DESIGNER;

// Seeded accounts are pre-verified so reviewers and operators can log in
// without access to the account's mailbox.
async function seedUser(email: string, password: string, role: UserRole, label: string) {
const existing = await prisma.user.findUnique({
where: { email: ADMIN_EMAIL.toLowerCase() },
where: { email: email.toLowerCase() },
});

if (existing) {
if (existing.role !== 'ADMIN') {
const updates: { role?: UserRole; emailVerified?: boolean } = {};
if (existing.role !== role) updates.role = role;
if (!existing.emailVerified) updates.emailVerified = true;

if (Object.keys(updates).length > 0) {
await prisma.user.update({
where: { id: existing.id },
data: { role: 'ADMIN' },
data: updates,
});
console.log(`Promoted ${ADMIN_EMAIL} to ADMIN.`);
console.log(`Updated ${label} user ${email}: ${Object.keys(updates).join(', ')}.`);
} else {
console.log(`${ADMIN_EMAIL} is already ADMIN.`);
console.log(`${email} is already a verified ${role}.`);
}
return;
}

const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 12);
const hashedPassword = await bcrypt.hash(password, 12);

await prisma.user.create({
data: {
email: ADMIN_EMAIL.toLowerCase(),
email: email.toLowerCase(),
password: hashedPassword,
role: 'ADMIN',
role,
emailVerified: true,
},
});

console.log(`Created admin user: ${ADMIN_EMAIL}`);
console.log(`Created ${label} user: ${email}`);
}

async function main() {
if (ADMIN_EMAIL && ADMIN_PASSWORD) {
await seedUser(ADMIN_EMAIL, ADMIN_PASSWORD, 'ADMIN', 'admin');
} else {
console.log('ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin seed.');
}

if (DEMO_EMAIL && DEMO_PASSWORD) {
await seedUser(DEMO_EMAIL, DEMO_PASSWORD, DEMO_ROLE, 'demo');
} else {
console.log('DEMO_EMAIL or DEMO_PASSWORD not set, skipping demo seed.');
}
}

main()
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ services:
JWT_EXPIRES_IN: 7d
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
DEMO_EMAIL: ${DEMO_EMAIL:-}
DEMO_PASSWORD: ${DEMO_PASSWORD:-}
DEMO_ROLE: ${DEMO_ROLE:-DSL_DESIGNER}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@example.com}
APP_URL: ${APP_URL:-https://dsl-studio.micss-lab.be}
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ services:
JWT_EXPIRES_IN: 7d
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
DEMO_EMAIL: ${DEMO_EMAIL:-}
DEMO_PASSWORD: ${DEMO_PASSWORD:-}
DEMO_ROLE: ${DEMO_ROLE:-DSL_DESIGNER}
SEED_DATABASE: ${SEED_DATABASE:-true}
command: ["sh", "-c", "npm run prisma:migrate:prod && if [ \"$$SEED_DATABASE\" = \"true\" ]; then npm run prisma:seed; fi && npm start"]
ports:
Expand Down
5 changes: 3 additions & 2 deletions docs/getting-started/docker-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Set at least:
Optional seed variables:

- `SEED_DATABASE=true` runs the backend seed script during backend startup. Development Docker defaults this to `true`; production Docker defaults it to `false`.
- `ADMIN_EMAIL` and `ADMIN_PASSWORD` let the current seed script create or promote an admin user. If either value is missing, the seed step exits without changing data.
- `ADMIN_EMAIL` and `ADMIN_PASSWORD` let the current seed script create or promote an admin user. If either value is missing, that seed step is skipped without changing data.
- `DEMO_EMAIL` and `DEMO_PASSWORD` let the seed script create a pre-verified demo account for reviewers and offline demos. `DEMO_ROLE` picks its role (`ADMIN`, `DSL_DESIGNER`, `MODELER`, or `VIEWER`; defaults to `DSL_DESIGNER`). Seeded accounts are marked email-verified, so they can log in without receiving a verification code.

## 2) Build and start all services

Expand Down Expand Up @@ -78,7 +79,7 @@ For production compose:
docker compose -f docker-compose.prod.yml up --build
```

Production does not run the seed script by default. To intentionally run it, set `SEED_DATABASE=true` in the production environment together with `ADMIN_EMAIL` and `ADMIN_PASSWORD`, start the backend once, then set `SEED_DATABASE=false` again for normal restarts.
Production does not run the seed script by default. To intentionally run it, set `SEED_DATABASE=true` in the production environment together with `ADMIN_EMAIL`/`ADMIN_PASSWORD` (and optionally `DEMO_EMAIL`/`DEMO_PASSWORD`), start the backend once, then set `SEED_DATABASE=false` again for normal restarts.

## 5) Verify stack health

Expand Down
9 changes: 5 additions & 4 deletions docs/reference/sirius-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SpatialDSL Studio now uses Sirius-style terminology and supports a web specifier
| Ecore metamodel import/export (`.ecore`) | Partial | Common single-package Ecore models round-trip. Cross-package references and custom datatypes are limited. |
| EMF XMI model import/export (`.xmi`) | Partial | Works for semantic instance data when a matching metamodel already exists. Layout and view data are not part of semantic XMI. |
| Sirius Viewpoint Specification Model (`.odesign`) import/export | Initial API slice | Backend validate/import/export endpoints cover a basic diagram subset and return compatibility reports. |
| Sirius session/representation files (`.aird`) import/export | Not implemented | SpatialDSL views are stored as app resources, not Sirius session artifacts. |
| Sirius session/representation files (`.aird`) import/export | Initial import subset | `.aird` diagram representations import as SpatialDSL views against an already-imported model and viewpoint; GMF notation layout (node bounds, edge waypoints) is preserved and unresolved references are reported. `.aird` export is not implemented; SpatialDSL views are stored as app resources, not Sirius session artifacts. |
| Sirius diagram mapping parity | Partial | Visible/creatable metaclasses, notation overrides, edge styling, and pin mappings exist. Full Sirius mappings, layers, filters, conditional styles, and tool operations are not complete. |
| Sirius table/tree editors | Not implemented | `table` and `tree` are reserved representation kinds. Only `diagram` is executable today. |

Expand Down Expand Up @@ -38,10 +38,11 @@ What moves through the initial Sirius compatibility API:
- Basic Sirius `.odesign` files with Viewpoints and Diagram Descriptions can be validated.
- Supported node, container, bordered-node/pin, edge, basic style, and simple tool data can be imported as SpatialDSL viewpoint records.
- SpatialDSL diagram representation descriptions can be exported as generated `.odesign` XML for the supported subset.
- Sirius `.aird` diagram representations can be validated and imported as SpatialDSL views: semantic targets resolve against an already-imported model (xmi:id fragment, then unique name), representation descriptions resolve against the selected viewpoint, GMF node bounds and edge waypoints are preserved, and unresolved or ambiguous references are reported rather than silently dropped.

What does not move today:

- Sirius `.aird` representations do not import into SpatialDSL.
- SpatialDSL views do not export to Sirius `.aird`; table and tree representations, layers, and filters inside `.aird` files are not imported.
- SpatialDSL view membership, layout, presentation overrides, pins, and bend points are not serialized into standard semantic XMI.
- Full Sirius `.odesign` fidelity is not implemented; unsupported layers, filters, conditional styles, Java services, and complex tool operations are reported.

Expand All @@ -65,7 +66,7 @@ Implemented or partially implemented:
Not yet on par with Sirius Desktop:

- full-fidelity `.odesign` import/export
- `.aird` session/representation import/export
- `.aird` export and full session fidelity (import covers a diagram-view subset)
- full node/container/edge mapping editor
- representation-specific creation/delete/direct-edit/reconnect tools
- operation/action language
Expand All @@ -80,4 +81,4 @@ Not yet on par with Sirius Desktop:

Use SpatialDSL as a Sirius-inspired web modeling workbench today. Use Ecore/XMI to exchange semantic language/model data with EMF-based tooling.

Do not assume Sirius Desktop project files are fully interchangeable with SpatialDSL project data yet. Use the `.odesign` interoperability endpoints for the documented supported subset, and treat `.aird` compatibility as a future interoperability feature.
Do not assume Sirius Desktop project files are fully interchangeable with SpatialDSL project data yet. Use the `.odesign` interoperability endpoints and the `.aird` view import for the documented supported subsets, and treat fuller `.aird` compatibility (export, complete sessions) as a future interoperability feature.
181 changes: 181 additions & 0 deletions frontend/src/__tests__/services/diagram-position-writethrough.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Model } from '../../models/types';

jest.mock('../../services/diagram/diagram-crud.service', () => ({
diagramCrudService: {
getDiagramById: jest.fn(),
getDiagramsRef: jest.fn(() => [])
}
}));

jest.mock('../../services/diagram/view-projection.service', () => ({
viewProjectionService: {
getIncludedElementIds: jest.fn(() => [])
}
}));

jest.mock('../../services/model', () => ({
modelService: {
getModelById: jest.fn(),
updateModelElementPresentation: jest.fn(() => true),
updateModelElementProperties: jest.fn(() => true)
}
}));

jest.mock('../../services/diagram/diagram-api-sync.service', () => ({
diagramApiSyncService: {
updateModelElementPresentation: jest.fn(() =>
Promise.resolve({ id: 'diagram-1', elements: [] })
)
}
}));

import { diagramService } from '../../services/diagram/diagram.service';
import { diagramCrudService } from '../../services/diagram/diagram-crud.service';
import { viewProjectionService } from '../../services/diagram/view-projection.service';
import { modelService } from '../../services/model';
import { diagramApiSyncService } from '../../services/diagram/diagram-api-sync.service';

const diagram = { id: 'diagram-1', modelId: 'model-1', elements: [] };

const makeModel = (presentation?: Record<string, any>): Model =>
({
id: 'model-1',
name: 'Warehouse',
metamodelId: 'mm-1',
conformsTo: 'mm-1',
elements: [
{
id: 'el-1',
modelElementId: 'class-rack',
style: { name: 'Rack A' },
references: {},
presentation
}
]
} as Model);

beforeEach(() => {
(diagramCrudService.getDiagramById as jest.Mock).mockReturnValue(diagram);
(diagramCrudService.getDiagramsRef as jest.Mock).mockReturnValue([]);
(viewProjectionService.getIncludedElementIds as jest.Mock).mockReturnValue(['el-1']);
(modelService.updateModelElementPresentation as jest.Mock).mockReturnValue(true);
(modelService.updateModelElementProperties as jest.Mock).mockReturnValue(true);
(diagramApiSyncService.updateModelElementPresentation as jest.Mock).mockResolvedValue({
id: 'diagram-1',
elements: []
});
});

describe('2D/3D position write-through', () => {
describe('updateElement (3D editor persist path)', () => {
it('mirrors a 3D move to position2D with the same values', () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 } })
);

diagramService.updateElement('diagram-1', 'el-1', {
style: { position3D: { x: 500, y: 700 } }
});

expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith(
'model-1',
'el-1',
expect.objectContaining({
position3D: { x: 500, y: 700 },
position2D: { x: 500, y: 700 }
})
);
});

it('mirrors a 2D x/y move to position3D when the element already has a world-space position', () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } })
);

diagramService.updateElement('diagram-1', 'el-1', { x: 30, y: 40 });

expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith(
'model-1',
'el-1',
expect.objectContaining({
position2D: { x: 30, y: 40 },
position3D: { x: 30, y: 40 }
})
);
});

it('does not create position3D for elements without a world-space position', () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 } })
);

diagramService.updateElement('diagram-1', 'el-1', { x: 30, y: 40 });

const persisted = (modelService.updateModelElementPresentation as jest.Mock).mock
.calls[0][2];
expect(persisted.position2D).toEqual({ x: 30, y: 40 });
expect(persisted.position3D).toBeUndefined();
});
});

describe('updateModelElementPresentationInView (2D editor persist path)', () => {
it('mirrors a 2D move to position3D when the element already has a world-space position', async () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } })
);

await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', {
position2D: { x: 10, y: 20 }
});

const expected = expect.objectContaining({
position2D: { x: 10, y: 20 },
position3D: { x: 10, y: 20 }
});
expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith(
'model-1',
'el-1',
expected
);
expect(diagramApiSyncService.updateModelElementPresentation).toHaveBeenCalledWith(
'diagram-1',
'el-1',
expected
);
});

it('leaves pure 2D notations untouched (no position3D is ever created)', async () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 } })
);

await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', {
position2D: { x: 10, y: 20 }
});

const persisted = (modelService.updateModelElementPresentation as jest.Mock).mock
.calls[0][2];
expect(persisted.position2D).toEqual({ x: 10, y: 20 });
expect(persisted.position3D).toBeUndefined();
});

it('mirrors a 3D-only presentation update back to position2D', async () => {
(modelService.getModelById as jest.Mock).mockReturnValue(
makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } })
);

await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', {
position3D: { x: 900, y: 450 }
});

expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith(
'model-1',
'el-1',
expect.objectContaining({
position3D: { x: 900, y: 450 },
position2D: { x: 900, y: 450 }
})
);
});
});
});
Loading