Implement shared survey access.
This revamps the whole access control system to use a separate ACL instead of relying on the "owner" field of the survey, allowing more granular definitions of access to surveys (cloning, editing, viewing results etc.) Closes #17
This commit is contained in:
parent
97e84aaf09
commit
309dc1c7df
35 changed files with 1383 additions and 131 deletions
17
drizzle/0003_overjoyed_shooting_star.sql
Normal file
17
drizzle/0003_overjoyed_shooting_star.sql
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
CREATE TABLE `survey_permissions_table` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`surveyId` int NOT NULL,
|
||||||
|
`user` int,
|
||||||
|
`access` int NOT NULL,
|
||||||
|
CONSTRAINT `survey_permissions_table_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `survey_user_un` UNIQUE(`surveyId`,`user`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `survey_permissions_table` ADD CONSTRAINT `survey_permissions_table_surveyId_surveys_table_id_fk` FOREIGN KEY (`surveyId`) REFERENCES `surveys_table`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `survey_permissions_table` ADD CONSTRAINT `survey_permissions_table_user_users_table_id_fk` FOREIGN KEY (`user`) REFERENCES `users_table`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `user_index` ON `survey_permissions_table` (`user`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `survey_index` ON `survey_permissions_table` (`surveyId`);
|
||||||
|
|
||||||
|
|
||||||
|
-- Create the owner permission for each existing survey
|
||||||
|
INSERT INTO survey_permissions_table (surveyId, user, access) SELECT id,owner,255 FROM surveys_table;
|
475
drizzle/meta/0003_snapshot.json
Normal file
475
drizzle/meta/0003_snapshot.json
Normal file
|
@ -0,0 +1,475 @@
|
||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "4d29fb1a-427c-4082-a176-08688a6d9f2e",
|
||||||
|
"prevId": "43f0adbb-ed0a-4d90-bb8a-1629fa070717",
|
||||||
|
"tables": {
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_userId_users_table_id_fk": {
|
||||||
|
"name": "sessions_userId_users_table_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"sessions_token": {
|
||||||
|
"name": "sessions_token",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"survey_access_table": {
|
||||||
|
"name": "survey_access_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"surveyId": {
|
||||||
|
"name": "surveyId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"recepientEmail": {
|
||||||
|
"name": "recepientEmail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"name": "accessToken",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"token_index": {
|
||||||
|
"name": "token_index",
|
||||||
|
"columns": [
|
||||||
|
"accessToken"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"survey_access_table_surveyId_surveys_table_id_fk": {
|
||||||
|
"name": "survey_access_table_surveyId_surveys_table_id_fk",
|
||||||
|
"tableFrom": "survey_access_table",
|
||||||
|
"tableTo": "surveys_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"surveyId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"survey_access_table_id": {
|
||||||
|
"name": "survey_access_table_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"survey_answers_table": {
|
||||||
|
"name": "survey_answers_table",
|
||||||
|
"columns": {
|
||||||
|
"participantId": {
|
||||||
|
"name": "participantId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"skillId": {
|
||||||
|
"name": "skillId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"name": "rating",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"survey_answers_table_participantId_survey_access_table_id_fk": {
|
||||||
|
"name": "survey_answers_table_participantId_survey_access_table_id_fk",
|
||||||
|
"tableFrom": "survey_answers_table",
|
||||||
|
"tableTo": "survey_access_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"participantId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"survey_answers_table_skillId_survey_skills_table_id_fk": {
|
||||||
|
"name": "survey_answers_table_skillId_survey_skills_table_id_fk",
|
||||||
|
"tableFrom": "survey_answers_table",
|
||||||
|
"tableTo": "survey_skills_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"skillId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"survey_answers_table_participantId_skillId_pk": {
|
||||||
|
"name": "survey_answers_table_participantId_skillId_pk",
|
||||||
|
"columns": [
|
||||||
|
"participantId",
|
||||||
|
"skillId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"survey_permissions_table": {
|
||||||
|
"name": "survey_permissions_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"surveyId": {
|
||||||
|
"name": "surveyId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"name": "access",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_index": {
|
||||||
|
"name": "user_index",
|
||||||
|
"columns": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"survey_index": {
|
||||||
|
"name": "survey_index",
|
||||||
|
"columns": [
|
||||||
|
"surveyId"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"survey_permissions_table_surveyId_surveys_table_id_fk": {
|
||||||
|
"name": "survey_permissions_table_surveyId_surveys_table_id_fk",
|
||||||
|
"tableFrom": "survey_permissions_table",
|
||||||
|
"tableTo": "surveys_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"surveyId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"survey_permissions_table_user_users_table_id_fk": {
|
||||||
|
"name": "survey_permissions_table_user_users_table_id_fk",
|
||||||
|
"tableFrom": "survey_permissions_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"survey_permissions_table_id": {
|
||||||
|
"name": "survey_permissions_table_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"survey_user_un": {
|
||||||
|
"name": "survey_user_un",
|
||||||
|
"columns": [
|
||||||
|
"surveyId",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"survey_skills_table": {
|
||||||
|
"name": "survey_skills_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"surveyId": {
|
||||||
|
"name": "surveyId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"survey_skills_table_surveyId_surveys_table_id_fk": {
|
||||||
|
"name": "survey_skills_table_surveyId_surveys_table_id_fk",
|
||||||
|
"tableFrom": "survey_skills_table",
|
||||||
|
"tableTo": "surveys_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"surveyId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"survey_skills_table_id": {
|
||||||
|
"name": "survey_skills_table_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"surveys_table": {
|
||||||
|
"name": "surveys_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"name": "owner",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"surveys_table_owner_users_table_id_fk": {
|
||||||
|
"name": "surveys_table_owner_users_table_id_fk",
|
||||||
|
"tableFrom": "surveys_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"surveys_table_id": {
|
||||||
|
"name": "surveys_table_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verification_code": {
|
||||||
|
"name": "verification_code",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verifcationCodeExpires": {
|
||||||
|
"name": "verifcationCodeExpires",
|
||||||
|
"type": "date",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"users_table_id": {
|
||||||
|
"name": "users_table_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_table_email_unique": {
|
||||||
|
"name": "users_table_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"tables": {},
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,13 @@
|
||||||
"when": 1734972757800,
|
"when": 1734972757800,
|
||||||
"tag": "0002_sweet_mentallo",
|
"tag": "0002_sweet_mentallo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1736628358111,
|
||||||
|
"tag": "0003_overjoyed_shooting_star",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
115
package-lock.json
generated
115
package-lock.json
generated
|
@ -26,6 +26,8 @@
|
||||||
"nodemailer": "^6.9.16"
|
"nodemailer": "^6.9.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@melt-ui/pp": "^0.3.2",
|
||||||
|
"@melt-ui/svelte": "^0.86.2",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@sveltejs/adapter-node": "^5.2.9",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
@ -1175,6 +1177,16 @@
|
||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@internationalized/date": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
@ -1439,6 +1451,68 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@melt-ui/pp": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@melt-ui/pp/-/pp-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@melt-ui/svelte": ">= 0.29.0",
|
||||||
|
"svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@melt-ui/pp/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@melt-ui/svelte": {
|
||||||
|
"version": "0.86.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.2.tgz",
|
||||||
|
"integrity": "sha512-wRVN603oIt1aXvx2QRmKqVDJgTScSvr/WJLLokkD8c4QzHgn6pfpPtUKmhV6Dvkk+OY89OG/1Irkd6ouA50Ztw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.3.1",
|
||||||
|
"@floating-ui/dom": "^1.4.5",
|
||||||
|
"@internationalized/date": "^3.5.0",
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"focus-trap": "^7.5.2",
|
||||||
|
"nanoid": "^5.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@melt-ui/svelte/node_modules/nanoid": {
|
||||||
|
"version": "5.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",
|
||||||
|
"integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz",
|
||||||
|
@ -2253,6 +2327,16 @@
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/forms": {
|
"node_modules/@tailwindcss/forms": {
|
||||||
"version": "0.5.9",
|
"version": "0.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
|
||||||
|
@ -3478,6 +3562,16 @@
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
|
||||||
|
@ -4181,6 +4275,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/focus-trap": {
|
||||||
|
"version": "7.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz",
|
||||||
|
"integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tabbable": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||||
|
@ -6279,6 +6383,13 @@
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
||||||
|
@ -6562,8 +6673,8 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"devOptional": true,
|
||||||
"optional": true
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@melt-ui/pp": "^0.3.2",
|
||||||
|
"@melt-ui/svelte": "^0.86.2",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@sveltejs/adapter-node": "^5.2.9",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
|
3
src/app.d.ts
vendored
3
src/app.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
|
import type { UserId } from "$lib/types";
|
||||||
import { Session } from "./lib/session/session";
|
import { Session } from "./lib/session/session";
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
|
@ -8,7 +9,7 @@ declare global {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
session: Session | null,
|
session: Session | null,
|
||||||
userId: number | null
|
userId: UserId | null
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|
|
@ -6,5 +6,5 @@ export const db = drizzle(
|
||||||
env.DATABASE_URL ?? '',
|
env.DATABASE_URL ?? '',
|
||||||
{
|
{
|
||||||
schema,
|
schema,
|
||||||
mode: 'default'
|
mode: 'default',
|
||||||
});
|
});
|
|
@ -1,4 +1,4 @@
|
||||||
import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date } from "drizzle-orm/mysql-core";
|
import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date, unique } from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
export const usersTable = mysqlTable("users_table", {
|
export const usersTable = mysqlTable("users_table", {
|
||||||
id: int().autoincrement().primaryKey(),
|
id: int().autoincrement().primaryKey(),
|
||||||
|
@ -44,3 +44,14 @@ export const surveyAnswersTable = mysqlTable("survey_answers_table", {
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.participantId, table.skillId] }),
|
pk: primaryKey({ columns: [table.participantId, table.skillId] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const surveyPermissionsTable = mysqlTable("survey_permissions_table", {
|
||||||
|
id: int().autoincrement().primaryKey(),
|
||||||
|
surveyId: int().notNull().references(() => surveysTable.id, { onDelete: "cascade" }),
|
||||||
|
user: int().references(() => usersTable.id, { onDelete: "cascade" }), // NULL means "any user"
|
||||||
|
access: int().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIndex: index("user_index").on(table.user),
|
||||||
|
surveyIndex: index("survey_index").on(table.surveyId),
|
||||||
|
survey_user_un: unique("survey_user_un").on(table.surveyId, table.user),
|
||||||
|
}));
|
||||||
|
|
188
src/db/survey.ts
188
src/db/survey.ts
|
@ -3,55 +3,144 @@ import debug from "debug";
|
||||||
|
|
||||||
const log = debug('survey:admin:edit');
|
const log = debug('survey:admin:edit');
|
||||||
|
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray, gte, sql } from "drizzle-orm";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema";
|
import { surveyAccessTable, surveyAnswersTable, surveyPermissionsTable, surveySkillsTable, surveysTable, usersTable } from "../db/schema";
|
||||||
import { generateRandomToken } from "$lib/randomToken";
|
import { AccessLevel, type AccessToken, type Email, type ParticipantId, type SkillId, type SurveyData, type SurveyId, type SurveyMetaData, type UserId } from "$lib/types";
|
||||||
|
import { checkAccess, whenAccess } from "$lib/helpers/shared/permissions";
|
||||||
|
import { generateRandomToken } from "$lib/helpers/shared/randomToken";
|
||||||
|
|
||||||
export async function loadSurvey(id: number, ownerId: number) {
|
/**
|
||||||
const survey = await db.select().from(surveysTable).where(and(eq(surveysTable.id, id), eq(surveysTable.owner, ownerId))).limit(1);
|
* Load the meta data for a single survey.
|
||||||
const participants = await db.select().from(surveyAccessTable).where(eq(surveyAccessTable.surveyId, id));
|
* @param surveyId The survey ID to load
|
||||||
const skills = await db.select().from(surveySkillsTable).where(eq(surveySkillsTable.surveyId, id));
|
* @param userId The ID of the user to load the survey for. This ensures proper access control
|
||||||
const answers = await db.select().from(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, participants.map(participant => participant.id)));
|
* @returns The survey meta data or null if no such survey exists or the user doesn't have access
|
||||||
|
*/
|
||||||
|
export async function loadSurveyMetadata(surveyId: SurveyId, userId: UserId): Promise<SurveyMetaData | null> {
|
||||||
|
const surveys = await db.select()
|
||||||
|
.from(surveyPermissionsTable)
|
||||||
|
.innerJoin(surveysTable, eq(surveysTable.id, surveyPermissionsTable.surveyId))
|
||||||
|
.where(and(eq(surveysTable.id, surveyId), eq(surveyPermissionsTable.user, userId))).limit(1);
|
||||||
|
|
||||||
if (survey.length === 0) {
|
if (surveys.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id: surveys[0].surveys_table.id as SurveyId,
|
||||||
title: survey[0].title,
|
title: surveys[0].surveys_table.title,
|
||||||
description: survey[0].description,
|
description: surveys[0].surveys_table.description,
|
||||||
participants: participants.map(participant => ({
|
permissions: surveys[0].survey_permissions_table.access as AccessLevel,
|
||||||
id: participant.id,
|
|
||||||
email: participant.recepientEmail,
|
|
||||||
accessToken: participant.accessToken,
|
|
||||||
answers: answers.filter(answer => answer.participantId === participant.id).map(answer => ({
|
|
||||||
skillId: answer.skillId,
|
|
||||||
rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors
|
|
||||||
}))
|
|
||||||
})),
|
|
||||||
skills: skills.map(skill => ({
|
|
||||||
id: skill.id,
|
|
||||||
title: skill.title,
|
|
||||||
description: skill.description
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSurveyData(surveyId: string, userId: number | null) {
|
/**
|
||||||
const sId = parseInt(surveyId);
|
* Load all permissions for a specific survey. This is different from the permissions field in a specific
|
||||||
|
* survey for a specific user, which gives the permissions for _that specific user_. This call returns all
|
||||||
|
* permissions for the survey and should only be available to owners.
|
||||||
|
*
|
||||||
|
* @param surveyId The ID of the survey to load the permissions for
|
||||||
|
*/
|
||||||
|
export async function loadSurveyPermissions(surveyId: SurveyId) {
|
||||||
|
let permissions = await db.select().from(surveyPermissionsTable).leftJoin(usersTable, eq(surveyPermissionsTable.user, usersTable.id)).where(eq(surveyPermissionsTable.surveyId, surveyId));
|
||||||
|
return permissions.map(p => ({
|
||||||
|
user: p.users_table ? {
|
||||||
|
id: p.users_table.id as UserId,
|
||||||
|
email: p.users_table.email as Email
|
||||||
|
} : null,
|
||||||
|
access: p.survey_permissions_table.access as AccessLevel
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(sId)) {
|
export async function loadMySurveys(userId: UserId): Promise<SurveyMetaData[]> {
|
||||||
log('Invalid survey ID %s', surveyId);
|
/// Get all surveys I have access to
|
||||||
error(404, 'Invalid survey ID');
|
const mySurves = await db.select()
|
||||||
|
.from(surveyPermissionsTable)
|
||||||
|
.innerJoin(surveysTable, eq(surveysTable.id, surveyPermissionsTable.surveyId))
|
||||||
|
.where(eq(surveyPermissionsTable.user, userId));
|
||||||
|
|
||||||
|
return mySurves.map(survey => ({
|
||||||
|
id: survey.surveys_table.id as SurveyId,
|
||||||
|
title: survey.surveys_table.title,
|
||||||
|
description: survey.surveys_table.description,
|
||||||
|
permissions: survey.survey_permissions_table.access,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the fill rates for the given surveys.
|
||||||
|
*
|
||||||
|
* Note: This will ignore surveys in the input where the user doesn't have the appropriate access to see the fill rate.
|
||||||
|
* @param surveys The surveys to load the fill rates for
|
||||||
|
* @returns The surveys as before, but will the fill rates field filled, where appropriate.
|
||||||
|
*/
|
||||||
|
export async function loadSurveyFillRates(surveys: SurveyMetaData[]): Promise<SurveyMetaData[]> {
|
||||||
|
const visibleSurveys = surveys.filter(survey => checkAccess(survey, AccessLevel.ReadResult)); // only ReadResult or above access allows users to see the fill rate
|
||||||
|
const fillRates = await db.select({
|
||||||
|
surveyId: surveyAccessTable.surveyId,
|
||||||
|
filled: sql`count(distinct(${surveyAnswersTable.participantId}))`.mapWith(Number),
|
||||||
|
expected: sql`count(distinct(${surveyAccessTable.id}))`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(surveyAccessTable)
|
||||||
|
.leftJoin(surveyAnswersTable, eq(surveyAccessTable.id, surveyAnswersTable.participantId))
|
||||||
|
.groupBy(surveyAccessTable.surveyId)
|
||||||
|
.where(inArray(surveyAccessTable.surveyId, visibleSurveys.map(survey => survey.id)));
|
||||||
|
|
||||||
|
return surveys.map(survey => ({
|
||||||
|
...survey,
|
||||||
|
fillRate: fillRates.find(fillRate => fillRate.surveyId === survey.id)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a survey in the context of a specific user
|
||||||
|
* @param id The ID of the survey in questions
|
||||||
|
* @param userId The ID of the user for which to load the survey. This will ensure correct access to the survey.
|
||||||
|
* @returns The data of the survey or null, if no such survey could be found (or the user doesn't have access to it)
|
||||||
|
*/
|
||||||
|
export async function loadSurvey(id: SurveyId, userId: UserId): Promise<SurveyData | null> {
|
||||||
|
const survey = await loadSurveyMetadata(id, userId);
|
||||||
|
|
||||||
|
if (!survey) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const participants = await whenAccess(survey, AccessLevel.Clone, () => db.select().from(surveyAccessTable).where(eq(surveyAccessTable.surveyId, id)));
|
||||||
|
const skills = await whenAccess(survey, AccessLevel.Clone, () => db.select().from(surveySkillsTable).where(eq(surveySkillsTable.surveyId, id)));
|
||||||
|
const answers = await whenAccess(survey, AccessLevel.ReadResult, () => {
|
||||||
|
if (participants) {
|
||||||
|
return db.select().from(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, participants.map(participant => participant.id)))
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...survey,
|
||||||
|
participants: participants?.map(participant => ({
|
||||||
|
id: participant.id as ParticipantId,
|
||||||
|
email: participant.recepientEmail as Email,
|
||||||
|
accessToken: whenAccess(survey, AccessLevel.Edit, () => participant.accessToken as AccessToken),
|
||||||
|
answers: answers?.filter(answer => answer.participantId === participant.id).map(answer => ({
|
||||||
|
skillId: answer.skillId as SkillId,
|
||||||
|
rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors
|
||||||
|
})) ?? []
|
||||||
|
})) ?? [],
|
||||||
|
skills: skills?.map(skill => ({
|
||||||
|
id: skill.id as SkillId,
|
||||||
|
title: skill.title,
|
||||||
|
description: skill.description
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSurveyData(surveyId: SurveyId, userId: UserId | null) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
log('User is not logged in');
|
log('User is not logged in');
|
||||||
error(403, 'User is not logged in');
|
error(403, 'User is not logged in');
|
||||||
}
|
}
|
||||||
|
|
||||||
const surveyData = await loadSurvey(sId, userId);
|
const surveyData = await loadSurvey(surveyId, userId);
|
||||||
if (!surveyData) {
|
if (!surveyData) {
|
||||||
log('Survey not found or user (%s) does not have access: %s', userId, surveyId);
|
log('Survey not found or user (%s) does not have access: %s', userId, surveyId);
|
||||||
error(404, 'Survey not found');
|
error(404, 'Survey not found');
|
||||||
|
@ -60,7 +149,7 @@ export async function loadSurveyData(surveyId: string, userId: number | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// add a new participant to a survey
|
// add a new participant to a survey
|
||||||
export async function addParticipant(surveyId: number, recepientEmail: string) {
|
export async function addParticipant(surveyId: SurveyId, recepientEmail: string) {
|
||||||
await db.insert(surveyAccessTable).values({ surveyId, recepientEmail, accessToken: generateRandomToken() });
|
await db.insert(surveyAccessTable).values({ surveyId, recepientEmail, accessToken: generateRandomToken() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,3 +157,36 @@ export async function addParticipant(surveyId: number, recepientEmail: string) {
|
||||||
export async function addSkill(surveyId: number, title: string, description: string) {
|
export async function addSkill(surveyId: number, title: string, description: string) {
|
||||||
await db.insert(surveySkillsTable).values({ surveyId, title, description });
|
await db.insert(surveySkillsTable).values({ surveyId, title, description });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether a given user has at least the given access level to the given survey. This is based on a database query and doesn't need the survey object already to be loaded
|
||||||
|
export async function hasAccess(surveyId: number, userId: number, accessLevel: AccessLevel): Promise<boolean> {
|
||||||
|
const result = await db.select().from(surveyPermissionsTable).where(and(eq(surveyPermissionsTable.surveyId, surveyId), eq(surveyPermissionsTable.user, userId), gte(surveyPermissionsTable.access, accessLevel)));
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A single entry in the permissions list
|
||||||
|
export type PermissionEntry = {
|
||||||
|
user: UserId | null,
|
||||||
|
access: AccessLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically set the permissions for a given survey. This will clear the existing permissions and set the news ones in one transactions to prevent race conditions that could leave users without access to their survey
|
||||||
|
export async function setPermissions(surveyId: SurveyId, permissions: PermissionEntry[]) {
|
||||||
|
const remainingUserIds = permissions.map(permission => permission.user);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(surveyPermissionsTable).where(eq(surveyPermissionsTable.surveyId, surveyId));
|
||||||
|
for (const permission of permissions) {
|
||||||
|
await tx.insert(surveyPermissionsTable).values({ surveyId, user: permission.user, access: permission.access }).onDuplicateKeyUpdate({
|
||||||
|
set: { access: permission.access }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant access to to a given survey for a given user. If the user already has access, the access level will be updated
|
||||||
|
export async function grantAccess(surveyId: SurveyId, userId: UserId | null, accessLevel: AccessLevel) {
|
||||||
|
await db.insert(surveyPermissionsTable).values({ surveyId, user: userId, access: accessLevel }).onDuplicateKeyUpdate({
|
||||||
|
set: { access: accessLevel }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,20 +1,15 @@
|
||||||
import { config } from "$lib/configuration";
|
import { config } from "$lib/configuration";
|
||||||
import { generateRandomToken } from "$lib/randomToken";
|
import type { Email, Password, User, UserId, VerificationCode } from "$lib/types";
|
||||||
import type { Email, Password, VerificationCode } from "$lib/types";
|
|
||||||
import { hash, verify } from "@node-rs/argon2";
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
import { db } from ".";
|
import { db } from ".";
|
||||||
import { usersTable } from "./schema";
|
import { usersTable } from "./schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { err, ok, type Result } from "$lib/result";
|
import { err, ok, type Result } from "$lib/result";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
|
import { generateRandomToken } from "$lib/helpers/shared/randomToken";
|
||||||
|
|
||||||
const log = debug('db:users');
|
const log = debug('db:users');
|
||||||
|
|
||||||
export type User = {
|
|
||||||
id: number;
|
|
||||||
email: Email;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> {
|
export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> {
|
||||||
const hashedPassword = await hash(password);
|
const hashedPassword = await hash(password);
|
||||||
|
|
||||||
|
@ -71,7 +66,7 @@ export async function verifyUser(verificationCode: VerificationCode): Promise<Re
|
||||||
await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode));
|
await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode));
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
id: user[0].id,
|
id: user[0].id as UserId,
|
||||||
email: user[0].email as Email
|
email: user[0].email as Email
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -3,6 +3,7 @@ import type { Handle } from "@sveltejs/kit";
|
||||||
import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session";
|
import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session";
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
import type { UserId } from "$lib/types";
|
||||||
|
|
||||||
const log = debug('hooks');
|
const log = debug('hooks');
|
||||||
|
|
||||||
|
@ -23,6 +24,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
event.locals.session = session;
|
event.locals.session = session;
|
||||||
event.locals.userId = session?.userId ?? null;
|
event.locals.userId = session?.userId as UserId ?? null;
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
93
src/lib/components/Autocomplete.svelte
Normal file
93
src/lib/components/Autocomplete.svelte
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<script lang="ts" generics="T">
|
||||||
|
import { createCombobox, melt } from '@melt-ui/svelte';
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options?: T[];
|
||||||
|
getOptions?: (searchTerm: string) => Promise<T[]>;
|
||||||
|
itemToString?: (item: T) => string;
|
||||||
|
selectedItem: T | undefined;
|
||||||
|
error?: string;
|
||||||
|
} & HTMLInputAttributes;
|
||||||
|
|
||||||
|
let {
|
||||||
|
getOptions,
|
||||||
|
itemToString,
|
||||||
|
selectedItem = $bindable(),
|
||||||
|
error,
|
||||||
|
class: cls,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const {
|
||||||
|
elements: { menu, input, option },
|
||||||
|
states: { open, inputValue, touchedInput, selected },
|
||||||
|
helpers: { isSelected }
|
||||||
|
} = createCombobox<T>({
|
||||||
|
forceVisible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = $derived.by(() => {
|
||||||
|
if ($touchedInput) {
|
||||||
|
return getOptions?.($inputValue);
|
||||||
|
}
|
||||||
|
if (selectedItem) {
|
||||||
|
return Promise.resolve([selectedItem]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// initialize the input field if we're loading an already existing value
|
||||||
|
if (selectedItem) {
|
||||||
|
selected.set({
|
||||||
|
value: selectedItem
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
selectedItem = $selected?.value;
|
||||||
|
if (!$open) {
|
||||||
|
if ($selected) {
|
||||||
|
$inputValue = itemToString?.($selected?.value) ?? String($selected.value);
|
||||||
|
} else {
|
||||||
|
$inputValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$inspect(selectedItem, $selected?.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full {error ? 'border-red-500' : ''} {cls}"
|
||||||
|
{...rest}
|
||||||
|
placeholder="Search for user's email address"
|
||||||
|
use:melt={$input}
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if open}
|
||||||
|
<ul
|
||||||
|
use:melt={$menu}
|
||||||
|
class="z-10 flex max-h-[300px] flex-col overflow-hidden border border-gray-300 bg-white"
|
||||||
|
>
|
||||||
|
{#await options}
|
||||||
|
<p>Loading...</p>
|
||||||
|
{:then options}
|
||||||
|
{#each options ?? [] as entry}
|
||||||
|
<li
|
||||||
|
class="data-[highlighted]:bg-gray-200"
|
||||||
|
use:melt={$option({
|
||||||
|
value: entry
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{itemToString?.(entry) ?? String(entry)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
23
src/lib/components/Button.svelte
Normal file
23
src/lib/components/Button.svelte
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
kind: 'primary' | 'secondary' | 'tertiary' | 'ghost';
|
||||||
|
} & HTMLButtonAttributes;
|
||||||
|
|
||||||
|
const { kind, children, class: cls, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
primary: 'bg-sky-200 hover:bg-sky-300 text-black border border-black rounded',
|
||||||
|
secondary: 'bg-slate-200 hover:bg-slate-300 text-black border border-black rounded',
|
||||||
|
tertiary: 'bg-white hover:bg-slate-100 text-black border border-slate-500 rounded',
|
||||||
|
ghost: 'bg-white hover:bg-slate-100 text-sky-600'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="{styles[
|
||||||
|
kind
|
||||||
|
]} disabled:cursor-not-allowed disabled:border-slate-400 disabled:bg-slate-100 disabled:text-slate-400 {cls}"
|
||||||
|
{...rest}>{@render children?.()}</button
|
||||||
|
>
|
12
src/lib/components/InlineWarning.svelte
Normal file
12
src/lib/components/InlineWarning.svelte
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
const { children, class: cls, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bold mx-auto max-w-xl border-2 border-red-700 bg-red-100 text-center text-red-500 {cls}"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
73
src/lib/components/UserSelector.svelte
Normal file
73
src/lib/components/UserSelector.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { AnyoneMarker, type Email, type User, type UserId } from '$lib/types';
|
||||||
|
import Autocomplete from './Autocomplete.svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
import ProfileIcon from './icons/ProfileIcon.svelte';
|
||||||
|
import XMark from './icons/XMark.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { error, user = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
async function getUsers(searchTerm: string) {
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
const result = await fetch(`../../users?query=${searchTerm}`);
|
||||||
|
return (await result.json()) as User[];
|
||||||
|
} catch (e) {
|
||||||
|
// ignore the errors because it's most likely a 400
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
// prevent breaking on the server side because we can have relative URLs in fetch
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_USER: User = {
|
||||||
|
id: 0 as UserId,
|
||||||
|
email: '' as Email
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<div class="relative">
|
||||||
|
<Autocomplete
|
||||||
|
name="users"
|
||||||
|
getOptions={getUsers}
|
||||||
|
itemToString={(user) => user.email}
|
||||||
|
bind:selectedItem={user}
|
||||||
|
{error}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
kind="ghost"
|
||||||
|
title="Allow anyone"
|
||||||
|
class="absolute right-1 top-2"
|
||||||
|
onclick={() => {
|
||||||
|
user = null;
|
||||||
|
}}><XMark /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="userIds" value={user.id} />
|
||||||
|
{:else}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div class="flex-grow">
|
||||||
|
Anyone<input type="hidden" name="users" value={AnyoneMarker} />
|
||||||
|
<input type="hidden" name="userIds" value={AnyoneMarker} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
kind="ghost"
|
||||||
|
title="Set user"
|
||||||
|
onclick={() => {
|
||||||
|
user = { ...EMPTY_USER };
|
||||||
|
}}><ProfileIcon /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
14
src/lib/components/icons/ShareIcon.svelte
Normal file
14
src/lib/components/icons/ShareIcon.svelte
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 473 B |
10
src/lib/components/icons/XMark.svelte
Normal file
10
src/lib/components/icons/XMark.svelte
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="size-6"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 225 B |
13
src/lib/helpers/backend/parse.ts
Normal file
13
src/lib/helpers/backend/parse.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
const log = debug('types');
|
||||||
|
|
||||||
|
export function parse<T extends number>(value: string): T {
|
||||||
|
const parsed = parseInt(value);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
log('Invalid ID %s', value);
|
||||||
|
error(400, 'Invalid ID');
|
||||||
|
}
|
||||||
|
return parsed as T;
|
||||||
|
}
|
21
src/lib/helpers/backend/permissions.ts
Normal file
21
src/lib/helpers/backend/permissions.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import type { AccessLevel, SurveyId, UserId } from "$lib/types";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { hasAccess } from "../../../db/survey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the given user has the required access level to the given survey. This will immediately return the appropriate HTTP error if the user doesn't have access
|
||||||
|
*
|
||||||
|
* @param surveyId The ID of the survey to check
|
||||||
|
* @param userId The ID of the user to check the access for
|
||||||
|
* @param accessLevel The required access level the user would need
|
||||||
|
*/
|
||||||
|
export async function ensureAccess(surveyId: SurveyId, userId: UserId | undefined | null, accessLevel: AccessLevel, errorMsg?: string): Promise<UserId> {
|
||||||
|
if (!userId) {
|
||||||
|
error(401, 'User is not logged in');
|
||||||
|
}
|
||||||
|
const userHasAccess = await hasAccess(surveyId, userId, accessLevel);
|
||||||
|
if (!userHasAccess) {
|
||||||
|
error(403, errorMsg ?? 'User does not have the required permission');
|
||||||
|
}
|
||||||
|
return userId
|
||||||
|
}
|
20
src/lib/helpers/shared/permissions.ts
Normal file
20
src/lib/helpers/shared/permissions.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
|
||||||
|
import type { AccessLevel, SurveyMetaData } from "$lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given survey has at least the required access level.
|
||||||
|
*
|
||||||
|
* @param survey The survey to check
|
||||||
|
* @param accessLevel The minimum access level required
|
||||||
|
*/
|
||||||
|
export function checkAccess(survey: SurveyMetaData, accessLevel: AccessLevel) {
|
||||||
|
return survey.permissions >= accessLevel; // Note: this simple check doesn't strictly need a function, but this makes it easier to read wherever we employ that condition
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the given action only if the user has the required access to the given survey
|
||||||
|
export function whenAccess<T>(survey: SurveyMetaData, accessLevel: AccessLevel, action: () => T): T | null {
|
||||||
|
if (checkAccess(survey, accessLevel)) {
|
||||||
|
return action();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { db } from '../../db';
|
||||||
import { sessions } from '../../db/schema';
|
import { sessions } from '../../db/schema';
|
||||||
import { eq, lt } from 'drizzle-orm';
|
import { eq, lt } from 'drizzle-orm';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import type { UserId } from '$lib/types';
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
@ -31,6 +32,7 @@ export async function validateSession(token: string) {
|
||||||
db.update(sessions).set({ expires: newExpires }).where(eq(sessions.token, token)); // refresh the session as long as the user is working in it
|
db.update(sessions).set({ expires: newExpires }).where(eq(sessions.token, token)); // refresh the session as long as the user is working in it
|
||||||
return {
|
return {
|
||||||
...session[0],
|
...session[0],
|
||||||
|
userId: session[0].userId as UserId,
|
||||||
expires: newExpires
|
expires: newExpires
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,4 +2,70 @@ import type { Branded } from "./branded";
|
||||||
|
|
||||||
export type Email = Branded<string, 'email'>;
|
export type Email = Branded<string, 'email'>;
|
||||||
export type Password = Branded<string, 'password'>;
|
export type Password = Branded<string, 'password'>;
|
||||||
|
export type AccessToken = Branded<string, 'accessToken'>;
|
||||||
export type VerificationCode = Branded<string, 'verificationCode'>;
|
export type VerificationCode = Branded<string, 'verificationCode'>;
|
||||||
|
export type SurveyId = Branded<number, 'surveyId'>;
|
||||||
|
export type UserId = Branded<number, 'userId'>;
|
||||||
|
export type ParticipantId = Branded<number, 'participantId'>;
|
||||||
|
export type SkillId = Branded<number, 'skillId'>;
|
||||||
|
|
||||||
|
|
||||||
|
/// Access level to a survey. Higher levels include lower levels
|
||||||
|
export enum AccessLevel {
|
||||||
|
/// Users may clone the survey
|
||||||
|
Clone = 10,
|
||||||
|
/// Users may read the results of a survey
|
||||||
|
ReadResult = 20,
|
||||||
|
/// Users may edit the survey
|
||||||
|
Edit = 30,
|
||||||
|
/// Users own this survey and may do whatever they like
|
||||||
|
Owner = 255,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shared user datatype
|
||||||
|
export type User = {
|
||||||
|
id: UserId;
|
||||||
|
email: Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// marker value for "any user" used in the form input fields
|
||||||
|
export const AnyoneMarker = '--(Anyone)--';
|
||||||
|
|
||||||
|
/// meta data for a specific survey
|
||||||
|
export type SurveyMetaData = {
|
||||||
|
id: SurveyId;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
fillRate?: {
|
||||||
|
filled: number;
|
||||||
|
expected: number;
|
||||||
|
};
|
||||||
|
/// the permission of the specific user in whose context the survey is loaded. This will not include other user's permissions
|
||||||
|
permissions: AccessLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SurveyAnswer = {
|
||||||
|
skillId: SkillId;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a specific participant in a survey
|
||||||
|
export type SurveyParticipant = {
|
||||||
|
id: ParticipantId;
|
||||||
|
email: Email;
|
||||||
|
accessToken: AccessToken | null;
|
||||||
|
answers: SurveyAnswer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a specific skill
|
||||||
|
export type SurveySkill = {
|
||||||
|
id: SkillId;
|
||||||
|
title: string;
|
||||||
|
description: string | undefined | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete data for a survey including skills & participants
|
||||||
|
export type SurveyData = SurveyMetaData & {
|
||||||
|
participants: SurveyParticipant[];
|
||||||
|
skills: SurveySkill[];
|
||||||
|
}
|
|
@ -1,33 +1,16 @@
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { db } from '../../db';
|
|
||||||
import { surveyAccessTable, surveyAnswersTable, surveysTable } from '../../db/schema';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
import { eq, inArray, not, sql } from 'drizzle-orm';
|
import { loadMySurveys, loadSurveyFillRates } from '../../db/survey';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
if (!locals.userId) {
|
if (!locals.userId) {
|
||||||
error(403, 'User is not logged in');
|
error(403, 'User is not logged in');
|
||||||
}
|
}
|
||||||
const mySurveys = await db.select().from(surveysTable).where(eq(surveysTable.owner, locals.userId));
|
const mySurveys = await loadMySurveys(locals.userId);
|
||||||
const fillRates = await db.select({
|
const surveyWithFillRates = await loadSurveyFillRates(mySurveys);
|
||||||
surveyId: surveyAccessTable.surveyId,
|
|
||||||
filled: sql`count(distinct(${surveyAnswersTable.participantId}))`.mapWith(Number),
|
|
||||||
expected: sql`count(distinct(${surveyAccessTable.id}))`.mapWith(Number),
|
|
||||||
})
|
|
||||||
.from(surveyAccessTable)
|
|
||||||
.leftJoin(surveyAnswersTable, eq(surveyAccessTable.id, surveyAnswersTable.participantId))
|
|
||||||
.groupBy(surveyAccessTable.surveyId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
surveys: mySurveys.map(survey => ({
|
surveys: surveyWithFillRates
|
||||||
id: survey.id,
|
|
||||||
title: survey.title,
|
|
||||||
description: survey.description,
|
|
||||||
fillRate: {
|
|
||||||
filled: fillRates.find(fillRate => fillRate.surveyId === survey.id)?.filled ?? 0,
|
|
||||||
expected: fillRates.find(fillRate => fillRate.surveyId === survey.id)?.expected ?? 0
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -38,12 +38,14 @@
|
||||||
<li class="grid grid-cols-2">
|
<li class="grid grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Link href="survey/{survey.id}">{survey.title}</Link>
|
<Link href="survey/{survey.id}">{survey.title}</Link>
|
||||||
|
{#if survey.fillRate}
|
||||||
<span class="mr-5 inline-block">
|
<span class="mr-5 inline-block">
|
||||||
({survey.fillRate.filled}/{survey.fillRate.expected})
|
({survey.fillRate.filled}/{survey.fillRate.expected})
|
||||||
{#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0}
|
{#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0}
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -4,16 +4,22 @@ import type { PageServerLoad, RouteParams } from './$types';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { deleteAnswers } from '../../../../db/answers';
|
import { deleteAnswers } from '../../../../db/answers';
|
||||||
import { loadSurveyData } from '../../../../db/survey';
|
import { loadSurveyData } from '../../../../db/survey';
|
||||||
|
import { AccessLevel, type SurveyId } from '$lib/types';
|
||||||
|
import { ensureAccess } from '$lib/helpers/backend/permissions';
|
||||||
|
import { parse } from '$lib/helpers/backend/parse';
|
||||||
|
|
||||||
const log = debug('survey:admin');
|
const log = debug('survey:admin');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
return await loadSurveyData(params.surveyId, locals.userId);
|
await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Clone, 'The current user cannot view this survey');
|
||||||
|
return await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
deleteAnswers: async ({ params, locals, request }) => {
|
deleteAnswers: async ({ params, locals, request }) => {
|
||||||
const survey = await loadSurveyData(params.surveyId, locals.userId);
|
await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
|
||||||
|
|
||||||
|
const survey = await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
|
||||||
|
|
||||||
let formData = await request.formData();
|
let formData = await request.formData();
|
||||||
const participantId = parseInt(formData.get('participantId')?.toString() ?? '');
|
const participantId = parseInt(formData.get('participantId')?.toString() ?? '');
|
||||||
|
|
|
@ -14,6 +14,9 @@
|
||||||
import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte';
|
import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte';
|
||||||
import WarningDialog from '$lib/components/WarningDialog.svelte';
|
import WarningDialog from '$lib/components/WarningDialog.svelte';
|
||||||
import EditIcon from '$lib/components/icons/EditIcon.svelte';
|
import EditIcon from '$lib/components/icons/EditIcon.svelte';
|
||||||
|
import ShareIcon from '$lib/components/icons/ShareIcon.svelte';
|
||||||
|
import { checkAccess } from '$lib/helpers/shared/permissions';
|
||||||
|
import { AccessLevel, type ParticipantId } from '$lib/types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
@ -21,14 +24,14 @@
|
||||||
if (data.participants.length > 0) {
|
if (data.participants.length > 0) {
|
||||||
return data.participants.map((participant) => ({
|
return data.participants.map((participant) => ({
|
||||||
skill: skill.title,
|
skill: skill.title,
|
||||||
participant: participant.id,
|
participant: participant.id as ParticipantId,
|
||||||
rating: participant.answers.find((answer) => answer.skillId === skill.id)?.rating
|
rating: participant.answers.find((answer) => answer.skillId === skill.id)?.rating
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// fallback pseudo participant because empty diagram data will break the diagram and make the survey page unaccessible
|
// fallback pseudo participant because empty diagram data will break the diagram and make the survey page unaccessible
|
||||||
return {
|
return {
|
||||||
skill: skill.title,
|
skill: skill.title,
|
||||||
participant: -1,
|
participant: -1 as ParticipantId,
|
||||||
rating: undefined
|
rating: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -43,6 +46,8 @@
|
||||||
|
|
||||||
let participantAnswersDeletionCandidateId = $state<number | null>(null);
|
let participantAnswersDeletionCandidateId = $state<number | null>(null);
|
||||||
|
|
||||||
|
$inspect(data);
|
||||||
|
|
||||||
async function deleteSurvey() {
|
async function deleteSurvey() {
|
||||||
await fetch('', {
|
await fetch('', {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
|
@ -56,6 +61,7 @@
|
||||||
<a href="/" class="flex items-center" aria-label="Home" title="Home">
|
<a href="/" class="flex items-center" aria-label="Home" title="Home">
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</a>
|
</a>
|
||||||
|
{#if checkAccess(data, AccessLevel.Clone)}
|
||||||
<a
|
<a
|
||||||
href="../survey/new?from={data.id}"
|
href="../survey/new?from={data.id}"
|
||||||
class="ml-2 flex items-center"
|
class="ml-2 flex items-center"
|
||||||
|
@ -63,6 +69,8 @@
|
||||||
title="Duplicate"
|
title="Duplicate"
|
||||||
><DuplicateIcon />
|
><DuplicateIcon />
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if checkAccess(data, AccessLevel.Edit)}
|
||||||
<a
|
<a
|
||||||
href="{data.id.toString()}/edit"
|
href="{data.id.toString()}/edit"
|
||||||
class="ml-2 flex items-center"
|
class="ml-2 flex items-center"
|
||||||
|
@ -70,6 +78,16 @@
|
||||||
title="Edit survey"
|
title="Edit survey"
|
||||||
><EditIcon />
|
><EditIcon />
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if checkAccess(data, AccessLevel.Owner)}
|
||||||
|
<a
|
||||||
|
href="{data.id.toString()}/share"
|
||||||
|
class="ml-2 flex items-center"
|
||||||
|
aria-label="Share"
|
||||||
|
title="Share survey with other users"
|
||||||
|
>
|
||||||
|
<ShareIcon />
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
deleteSurveyDialogRef?.showModal();
|
deleteSurveyDialogRef?.showModal();
|
||||||
|
@ -79,6 +97,7 @@
|
||||||
title="Delete"
|
title="Delete"
|
||||||
><TrashIcon />
|
><TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
{#if data.description}
|
{#if data.description}
|
||||||
|
@ -95,6 +114,7 @@
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{#if participant.accessToken}
|
||||||
<button
|
<button
|
||||||
aria-label="Copy link to clipboard"
|
aria-label="Copy link to clipboard"
|
||||||
class="text-sky-700"
|
class="text-sky-700"
|
||||||
|
@ -106,6 +126,7 @@
|
||||||
>
|
>
|
||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if participant.answers.length > 0}
|
{#if participant.answers.length > 0}
|
||||||
<button
|
<button
|
||||||
aria-label="Reset ratings"
|
aria-label="Reset ratings"
|
||||||
|
@ -123,9 +144,13 @@
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 justify-items-center">
|
{#if checkAccess(data, AccessLevel.ReadResult)}
|
||||||
|
<div class="grid grid-cols-1 justify-items-center">
|
||||||
<Diagram data={diagramData} />
|
<Diagram data={diagramData} />
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center">You do not have permission to see the survey results</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<WarningDialog title="Delete survey" bind:dialogRef={deleteSurveyDialogRef} onAccept={deleteSurvey}>
|
<WarningDialog title="Delete survey" bind:dialogRef={deleteSurveyDialogRef} onAccept={deleteSurvey}>
|
||||||
<p>Are you sure you want to delete the survey.</p>
|
<p>Are you sure you want to delete the survey.</p>
|
||||||
|
|
|
@ -7,19 +7,20 @@ import { db } from '../../../../../db';
|
||||||
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from '../../../../../db/schema';
|
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from '../../../../../db/schema';
|
||||||
import { eq, inArray, and } from 'drizzle-orm';
|
import { eq, inArray, and } from 'drizzle-orm';
|
||||||
import { addParticipant, addSkill, loadSurveyData } from '../../../../../db/survey';
|
import { addParticipant, addSkill, loadSurveyData } from '../../../../../db/survey';
|
||||||
|
import { ensureAccess } from '$lib/helpers/backend/permissions';
|
||||||
|
import { parse } from '$lib/helpers/backend/parse';
|
||||||
|
import { AccessLevel, type SurveyId } from '$lib/types';
|
||||||
|
|
||||||
const log = debug('survey:admin:edit');
|
const log = debug('survey:admin:edit');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
return await loadSurveyData(params.surveyId, locals.userId);
|
await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
|
||||||
|
return await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, params, locals }) => {
|
default: async ({ request, params, locals }) => {
|
||||||
const owner = locals.userId;
|
await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
|
||||||
if (!owner) {
|
|
||||||
error(400, 'User is not logged in');
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
@ -32,7 +33,8 @@ export const actions = {
|
||||||
error(400, 'At least one skill is required');
|
error(400, 'At least one skill is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const survey = await loadSurveyData(params.surveyId, locals.userId);
|
// TODO this should probably be handled in a single transaction
|
||||||
|
const survey = await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
|
||||||
|
|
||||||
// update the actual survey object
|
// update the actual survey object
|
||||||
await db.update(surveysTable).set({ title, description }).where(eq(surveysTable.id, survey.id));
|
await db.update(surveysTable).set({ title, description }).where(eq(surveysTable.id, survey.id));
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import InlineWarning from '$lib/components/InlineWarning.svelte';
|
||||||
import Navbar from '$lib/components/Navbar.svelte';
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
|
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
|
||||||
import type { PageData } from '../$types';
|
import type { PageData } from '../$types';
|
||||||
|
@ -8,13 +9,13 @@
|
||||||
|
|
||||||
<Navbar title="Edit survey" />
|
<Navbar title="Edit survey" />
|
||||||
{#if data.participants.some((participant) => participant.answers.length > 0)}
|
{#if data.participants.some((participant) => participant.answers.length > 0)}
|
||||||
<div class="bold mx-auto max-w-xl border-2 border-red-700 bg-red-100 text-center text-red-500">
|
<InlineWarning>
|
||||||
<p>There are participants who have already submitted ratings.</p>
|
<p>There are participants who have already submitted ratings.</p>
|
||||||
<p>
|
<p>
|
||||||
Changing the structure of this survey (i.e. changing any of the skills) will potentially
|
Changing the structure of this survey (i.e. changing any of the skills) will potentially
|
||||||
invalidate their answers. Consider either deleting their answers before changing the skills or
|
invalidate their answers. Consider either deleting their answers before changing the skills or
|
||||||
leave the skills as is (e.g. only add/remove participants).
|
leave the skills as is (e.g. only add/remove participants).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</InlineWarning>
|
||||||
{/if}
|
{/if}
|
||||||
<SurveyEditForm {...data} submitButtonTitle="Save" />
|
<SurveyEditForm {...data} submitButtonTitle="Save" />
|
||||||
|
|
40
src/routes/(app)/survey/[surveyId]/share/+page.server.ts
Normal file
40
src/routes/(app)/survey/[surveyId]/share/+page.server.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { parse } from "$lib/helpers/backend/parse";
|
||||||
|
import { ensureAccess } from "$lib/helpers/backend/permissions";
|
||||||
|
import { AccessLevel, AnyoneMarker, type SurveyId, type UserId } from "$lib/types";
|
||||||
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
import type { PageServerLoad } from "../$types";
|
||||||
|
import { loadSurveyMetadata, loadSurveyPermissions, setPermissions, type PermissionEntry } from "../../../../../db/survey";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const surveyId = parse<SurveyId>(params.surveyId);
|
||||||
|
let userId = await ensureAccess(surveyId, locals.userId, AccessLevel.Owner, "Only the survey owner can give access to the survey");
|
||||||
|
|
||||||
|
const survey = await loadSurveyMetadata(surveyId, userId);
|
||||||
|
const permissions = await loadSurveyPermissions(surveyId);
|
||||||
|
return { survey, permissions: permissions.map(p => ({ user: p.user, access: p.access })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, params, locals }) => {
|
||||||
|
const surveyId = parse<SurveyId>(params.surveyId);
|
||||||
|
await ensureAccess(surveyId, locals.userId, AccessLevel.Owner, 'The current user cannot edit the permissions of this survey');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const permissions = formData.getAll("permissions");
|
||||||
|
const userPermissions: PermissionEntry[] = formData.getAll("userIds").map((userId, index) => ({
|
||||||
|
user: userId === AnyoneMarker ? null : parse<UserId>(userId.toString()),
|
||||||
|
access: parse<AccessLevel>(permissions[index].toString())
|
||||||
|
}));
|
||||||
|
|
||||||
|
// some basic validation to prevent users from creating surveys without owners
|
||||||
|
if (!userPermissions.some(p => p.access === AccessLevel.Owner)) {
|
||||||
|
console.log("No owner", userPermissions);
|
||||||
|
error(400, "A survey must have at least one owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPermissions(surveyId, userPermissions);
|
||||||
|
|
||||||
|
redirect(303, `.`);
|
||||||
|
}
|
||||||
|
}
|
73
src/routes/(app)/survey/[surveyId]/share/+page.svelte
Normal file
73
src/routes/(app)/survey/[surveyId]/share/+page.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import Autocomplete from '$lib/components/Autocomplete.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
|
import { AccessLevel, AnyoneMarker } from '$lib/types';
|
||||||
|
import type { UserId, Email } from '$lib/types';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import InlineWarning from '$lib/components/InlineWarning.svelte';
|
||||||
|
import TrashIcon from '$lib/components/icons/TrashIcon.svelte';
|
||||||
|
import UserSelector from '$lib/components/UserSelector.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let permissions = $state(data.permissions);
|
||||||
|
|
||||||
|
const hasOwner = $derived(permissions.some((p) => p.access === AccessLevel.Owner));
|
||||||
|
const isDuplicated = $derived(
|
||||||
|
permissions.map((p, idx) =>
|
||||||
|
permissions.some((candidate, cidx) => idx !== cidx && p.user?.id === candidate.user?.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const hasErrors = $derived(!hasOwner || isDuplicated.some((b) => b));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Navbar title="Permissions for: {data.survey?.title}" />
|
||||||
|
|
||||||
|
{#if !hasOwner}
|
||||||
|
<InlineWarning class="mb-5">
|
||||||
|
<p>A survey MUST have at least one owner.</p>
|
||||||
|
</InlineWarning>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" class="mx-4 grid grid-cols-[1fr_1fr_max-content] gap-2">
|
||||||
|
{#each permissions as permission, idx}
|
||||||
|
<UserSelector
|
||||||
|
bind:user={permission.user}
|
||||||
|
error={isDuplicated[idx] ? 'Cannot add user twice' : undefined}
|
||||||
|
/>
|
||||||
|
<select name="permissions" bind:value={permission.access}>
|
||||||
|
<option value={AccessLevel.Clone}>Clone</option>
|
||||||
|
<option value={AccessLevel.ReadResult}>Read results</option>
|
||||||
|
<option value={AccessLevel.Edit}>Edit</option>
|
||||||
|
<option value={AccessLevel.Owner}>Owner</option>
|
||||||
|
</select>
|
||||||
|
<div>
|
||||||
|
<Button kind="ghost" onclick={() => permissions.splice(idx, 1)} title="Remove entry"
|
||||||
|
><TrashIcon /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
class="col-[2] w-40 justify-self-center"
|
||||||
|
kind="secondary"
|
||||||
|
onclick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
permissions.push({
|
||||||
|
user: {
|
||||||
|
email: '' as Email,
|
||||||
|
id: 0 as UserId
|
||||||
|
},
|
||||||
|
access: AccessLevel.ReadResult
|
||||||
|
});
|
||||||
|
}}>Add user</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
kind="primary"
|
||||||
|
class="col-span-2 w-40 justify-self-center p-3"
|
||||||
|
disabled={hasErrors}>Update permissions</Button
|
||||||
|
>
|
||||||
|
</form>
|
|
@ -1,14 +1,18 @@
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { surveysTable } from '../../../../db/schema';
|
import { surveyPermissionsTable, surveysTable } from '../../../../db/schema';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { fromFormData } from '$lib/survey';
|
import { fromFormData } from '$lib/survey';
|
||||||
import { addParticipant, addSkill, loadSurvey } from '../../../../db/survey';
|
import { addParticipant, addSkill, loadSurvey } from '../../../../db/survey';
|
||||||
|
import { AccessLevel, type SurveyId } from '$lib/types';
|
||||||
|
import { ensureAccess } from '$lib/helpers/backend/permissions';
|
||||||
|
import { parse } from '$lib/helpers/backend/parse';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const baseSurveyId = url.searchParams.get('from');
|
const baseSurveyId = url.searchParams.get('from');
|
||||||
if (baseSurveyId) {
|
if (baseSurveyId && locals.userId) {
|
||||||
const baseSurvey = await loadSurvey(parseInt(baseSurveyId), locals.userId ?? 0);
|
await ensureAccess(parse<SurveyId>(baseSurveyId), locals.userId, AccessLevel.Clone, 'The current user cannot clone this survey');
|
||||||
|
const baseSurvey = await loadSurvey(parse<SurveyId>(baseSurveyId), locals.userId);
|
||||||
return baseSurvey;
|
return baseSurvey;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -35,9 +39,13 @@ export const actions = {
|
||||||
error(400, 'At least one skill is required');
|
error(400, 'At least one skill is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO those should probably be handled in a single transaction
|
||||||
const ids = await db.insert(surveysTable).values({ title, description, owner }).$returningId();
|
const ids = await db.insert(surveysTable).values({ title, description, owner }).$returningId();
|
||||||
|
|
||||||
const surveyId = ids[0].id;
|
const surveyId = ids[0].id as SurveyId;
|
||||||
|
// insert the owner permission for this survey
|
||||||
|
db.insert(surveyPermissionsTable).values({ surveyId, user: owner, access: AccessLevel.Owner });
|
||||||
|
// record all participants & skills
|
||||||
for (const participant of participants) {
|
for (const participant of participants) {
|
||||||
await addParticipant(surveyId, participant);
|
await addParticipant(surveyId, participant);
|
||||||
}
|
}
|
||||||
|
|
19
src/routes/(app)/users/+server.ts
Normal file
19
src/routes/(app)/users/+server.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||||
|
import { db } from "../../../db";
|
||||||
|
import { usersTable } from "../../../db/schema";
|
||||||
|
import { like } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.userId) {
|
||||||
|
return new Response(null, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = url.searchParams.get('query');
|
||||||
|
if ((searchTerm?.length ?? 0) < 3) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Query must be at least 3 characters long' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db.select({ id: usersTable.id, email: usersTable.email }).from(usersTable).where(like(usersTable.email, `%${searchTerm}%`));
|
||||||
|
|
||||||
|
return json(users);
|
||||||
|
};
|
|
@ -6,10 +6,10 @@ import { usersTable } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { verify } from '@node-rs/argon2';
|
import { verify } from '@node-rs/argon2';
|
||||||
import { generateRandomToken } from '$lib/randomToken';
|
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { config } from '$lib/configuration';
|
import { config } from '$lib/configuration';
|
||||||
|
import { generateRandomToken } from '$lib/helpers/shared/randomToken';
|
||||||
|
|
||||||
let log = debug('login');
|
let log = debug('login');
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { preprocessMeltUI, sequence } from '@melt-ui/pp'
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: sequence([
|
||||||
|
vitePreprocess(),
|
||||||
|
preprocessMeltUI() // add to the end!
|
||||||
|
]),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
|
Loading…
Add table
Reference in a new issue