From c4e836a85f9e048d4b85315d0cf86435ba134a4d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 14 Aug 2026 09:54:09 +0200 Subject: [PATCH] feat(admin): implement delete functionality for Falukant regions and update UI components --- backend/controllers/adminController.js | 14 + ...000000-repair-bisaya-baynte-srs-answer.cjs | 63 +++ backend/routers/adminRouter.js | 1 + backend/services/adminService.js | 39 ++ frontend/src/i18n/locales/de/admin.json | 28 +- frontend/src/i18n/locales/en/admin.json | 28 +- .../views/admin/falukant/MapRegionsView.vue | 417 +++++++++++++++--- 7 files changed, 522 insertions(+), 68 deletions(-) create mode 100644 backend/migrations-active/20260813000000-repair-bisaya-baynte-srs-answer.cjs diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 65048e4..b7fe107 100755 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -57,6 +57,7 @@ class AdminController { this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this); this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this); this.createFalukantRegion = this.createFalukantRegion.bind(this); + this.deleteFalukantRegion = this.deleteFalukantRegion.bind(this); this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this); this.getRegionDistances = this.getRegionDistances.bind(this); this.upsertRegionDistance = this.upsertRegionDistance.bind(this); @@ -622,6 +623,19 @@ class AdminController { } } + async deleteFalukantRegion(req, res) { + try { + const { userid: userId } = req.headers; + const result = await AdminService.deleteFalukantRegion(userId, req.params.id); + res.status(200).json(result); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 + : (['regionNotFound'].includes(error.message) ? 404 : 400); + res.status(status).json({ error: error.message }); + } + } + async updateFalukantRegionMap(req, res) { try { const { userid: userId } = req.headers; diff --git a/backend/migrations-active/20260813000000-repair-bisaya-baynte-srs-answer.cjs b/backend/migrations-active/20260813000000-repair-bisaya-baynte-srs-answer.cjs new file mode 100644 index 0000000..8b2ea1b --- /dev/null +++ b/backend/migrations-active/20260813000000-repair-bisaya-baynte-srs-answer.cjs @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Older generated SRS data contained the number word "Baynte" as the + * currency phrase "20 Peso". Baynte itself means twenty, so the expected + * answer must be the number without a currency unit. + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + WITH affected AS ( + SELECT + id, + user_id, + course_id, + lesson_id, + direction, + CASE + WHEN LOWER(TRIM(learning)) = 'baynte' THEN 'baynte' + ELSE '20' + END AS corrected_learning, + CASE + WHEN LOWER(TRIM(reference)) = 'baynte' THEN 'baynte' + ELSE '20' + END AS corrected_reference + FROM community.vocab_srs_item + WHERE (LOWER(TRIM(learning)) = 'baynte' AND LOWER(TRIM(reference)) IN ('20 peso', '20 pesos')) + OR (LOWER(TRIM(reference)) = 'baynte' AND LOWER(TRIM(learning)) IN ('20 peso', '20 pesos')) + ), + keyed AS ( + SELECT + affected.*, + ENCODE(DIGEST(CONCAT_WS( + '|', + course_id::text, + COALESCE(lesson_id::text, 'course'), + UPPER(direction), + corrected_learning, + corrected_reference + ), 'sha1'), 'hex') AS corrected_item_key + FROM affected + ) + UPDATE community.vocab_srs_item item + SET learning = keyed.corrected_learning, + reference = keyed.corrected_reference, + item_key = keyed.corrected_item_key, + updated_at = NOW() + FROM keyed + WHERE item.id = keyed.id + AND NOT EXISTS ( + SELECT 1 + FROM community.vocab_srs_item duplicate + WHERE duplicate.user_id = keyed.user_id + AND duplicate.item_key = keyed.corrected_item_key + AND duplicate.id <> keyed.id + ); + `); + }, + + async down() { + // The old answer was semantically incorrect and must not be restored. + }, +}; diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index b30dc34..bd367c1 100755 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -65,6 +65,7 @@ router.get('/falukant/region-types', authenticate, adminController.getFalukantRe router.get('/falukant/regions', authenticate, adminController.getFalukantRegions); router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions); router.post('/falukant/regions', authenticate, adminController.createFalukantRegion); +router.delete('/falukant/regions/:id', authenticate, adminController.deleteFalukantRegion); router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap); router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances); router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index ad721b8..2b29612 100755 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -934,6 +934,45 @@ class AdminService { return region; } + async deleteFalukantRegion(userId, regionId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const region = await RegionData.findByPk(regionId); + if (!region) { + throw new Error('regionNotFound'); + } + + const [childCount, distanceCount, userCount, characterCount, weatherCount] = await Promise.all([ + RegionData.count({ where: { parentId: region.id } }), + RegionDistance.count({ + where: { + [Op.or]: [ + { sourceRegionId: region.id }, + { targetRegionId: region.id }, + ], + }, + }), + FalukantUser.count({ where: { mainBranchRegionId: region.id } }), + FalukantCharacter.count({ where: { regionId: region.id } }), + Weather.count({ where: { regionId: region.id } }), + ]); + + if (childCount > 0) { + throw new Error('regionHasChildren'); + } + if (distanceCount > 0) { + throw new Error('regionHasDistances'); + } + if (userCount > 0 || characterCount > 0 || weatherCount > 0) { + throw new Error('regionInUse'); + } + + await region.destroy(); + return { success: true }; + } + async getRegionDistances(userId) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) { throw new Error('noaccess'); diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index 69b0cc8..dbd1476 100755 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -262,10 +262,35 @@ "distances": "Entfernungen" }, "regionList": "Regionen", + "mapArea": "Karten-Arbeitsbereich", + "selectedRegion": "Ausgewählte Region", + "selectRegionHint": "Wähle eine Region aus der Liste oder direkt auf der Karte aus.", + "unknownType": "Unbekannter Typ", + "coordsCompact": "X {x}, Y {y} · B {w} × H {h}", "noCoords": "Keine Koordinaten gesetzt", + "positionSet": "Position gesetzt", + "coordinateX": "X-Position", + "coordinateY": "Y-Position", + "coordinateWidth": "Breite", + "coordinateHeight": "Höhe", "currentRect": "Aktuelles Rechteck", "hintDraw": "Wähle eine Region und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.", "saveAll": "Alle geänderten Regionen speichern", + "saving": "Speichere…", + "saveSuccess": "Region wurde gespeichert.", + "saveAllSuccess": "Alle Änderungen wurden gespeichert.", + "errorSave": "Die Region konnte nicht gespeichert werden.", + "unsaved": "Nicht gespeichert", + "deleteRegion": "Region löschen", + "deleting": "Lösche…", + "deleteHint": "Das Löschen ist nur möglich, wenn keine Unterregionen oder Entfernungen vorhanden sind.", + "confirmDeleteRegion": "Region „{name}“ wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.", + "deleteSuccess": "Region wurde gelöscht.", + "deleteError": "Die Region konnte nicht gelöscht werden.", + "deleteBlockedChildren": "Diese Region kann nicht gelöscht werden, weil sie Unterregionen enthält.", + "deleteBlockedDistances": "Diese Region kann nicht gelöscht werden, weil sie in Entfernungsverbindungen verwendet wird.", + "deleteBlockedInUse": "Diese Region kann nicht gelöscht werden, weil sie bereits von Spiel- oder Wetterdaten verwendet wird.", + "deleteBlockedNotFound": "Die Region wurde bereits gelöscht oder existiert nicht mehr.", "createRegion": { "title": "Neue Region anlegen", "type": "Regionstyp", @@ -276,7 +301,8 @@ "name": "Name", "create": "Region anlegen", "creating": "Lege an…", - "error": "Region konnte nicht angelegt werden." + "error": "Region konnte nicht angelegt werden.", + "success": "Region wurde angelegt." }, "connectionsTitle": "Verbindungen (region_distance)", "source": "Von", diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 35e5b2b..f32f226 100755 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -317,10 +317,35 @@ "distances": "Distances" }, "regionList": "Regions", + "mapArea": "Map workspace", + "selectedRegion": "Selected region", + "selectRegionHint": "Select a region from the list or directly on the map.", + "unknownType": "Unknown type", + "coordsCompact": "X {x}, Y {y} · W {w} × H {h}", "noCoords": "No coordinates set", + "positionSet": "Position set", + "coordinateX": "X position", + "coordinateY": "Y position", + "coordinateWidth": "Width", + "coordinateHeight": "Height", "currentRect": "Current rectangle", "hintDraw": "Select a region and drag a rectangle on the map to set its position.", "saveAll": "Save all changed regions", + "saving": "Saving…", + "saveSuccess": "Region saved.", + "saveAllSuccess": "All changes saved.", + "errorSave": "Could not save the region.", + "unsaved": "Unsaved", + "deleteRegion": "Delete region", + "deleting": "Deleting…", + "deleteHint": "Deletion is available only when the region has no child regions or distance connections.", + "confirmDeleteRegion": "Delete region “{name}”? This cannot be undone.", + "deleteSuccess": "Region deleted.", + "deleteError": "Could not delete the region.", + "deleteBlockedChildren": "This region cannot be deleted because it has child regions.", + "deleteBlockedDistances": "This region cannot be deleted because it is used by distance connections.", + "deleteBlockedInUse": "This region cannot be deleted because it is already used by game or weather data.", + "deleteBlockedNotFound": "This region has already been deleted or no longer exists.", "createRegion": { "title": "Create new region", "type": "Region type", @@ -331,7 +356,8 @@ "name": "Name", "create": "Create region", "creating": "Creating…", - "error": "Could not create region." + "error": "Could not create region.", + "success": "Region created." }, "connectionsTitle": "Connections (region_distance)", "source": "From", diff --git a/frontend/src/views/admin/falukant/MapRegionsView.vue b/frontend/src/views/admin/falukant/MapRegionsView.vue index 9a8f4c9..7582daf 100755 --- a/frontend/src/views/admin/falukant/MapRegionsView.vue +++ b/frontend/src/views/admin/falukant/MapRegionsView.vue @@ -6,13 +6,18 @@
-
+
+
+ + {{ $t('admin.falukant.map.hintDraw') }} +
+
-
+
+ - -
-

{{ selectedRegion.name }}

-

- {{ $t('admin.falukant.map.currentRect') }}: - {{ selectedRegion.map.x }},{{ selectedRegion.map.y }} - {{ selectedRegion.map.w }}×{{ selectedRegion.map.h }} -

-

- {{ $t('admin.falukant.map.noCoords') }} -

-

- {{ $t('admin.falukant.map.hintDraw') }} -

- - -
+
+
+
+

{{ $t('admin.falukant.map.selectedRegion') }}

+

{{ selectedRegion.name }}

+
+ {{ regionTypeName(selectedRegion) }} +
+
+ + {{ selectedRegion.map ? $t('admin.falukant.map.positionSet') : $t('admin.falukant.map.noCoords') }} + + {{ $t('admin.falukant.map.unsaved') }} +
+
+
{{ $t('admin.falukant.map.coordinateX') }}
{{ selectedRegion.map.x }}
+
{{ $t('admin.falukant.map.coordinateY') }}
{{ selectedRegion.map.y }}
+
{{ $t('admin.falukant.map.coordinateWidth') }}
{{ selectedRegion.map.w }}
+
{{ $t('admin.falukant.map.coordinateHeight') }}
{{ selectedRegion.map.h }}
+
+
+ + +
+
+ {{ $t('admin.falukant.map.deleteHint') }} + +
+
+

{{ $t('admin.falukant.map.selectRegionHint') }}

@@ -261,7 +276,7 @@
- + @@ -269,7 +284,7 @@