Skip to content

Repository files navigation

j26-tags

RFID tag registry for Jamboree26. Stores which group a physical tag belongs to, so lost tags can be traced back to their owner and other J26 services can resolve a tag id programmatically.

Runs as a micro-frontend in an iframe inside the j26-app shell, mounted at /_services/tags.

What it does

  • Lookup page (/) — type or NFC-scan a tag id, see which group it belongs to and where. For lost-and-found staff.
  • Admin UI (/admin) — CRUD over tags, groups and members, search/filter, and CSV bulk import.
  • Machine API (/api/tags) — look tags up and register new ones, authenticated with a single static API key. For hardware readers and other services.

Data model

A tag stores only references — a group number, a member number, or both. The group name and the coordinates live in two lookup tables and are resolved when a tag is read:

Tag     id (UID)  →  groupId?  memberNo?
Group   groupId   →  name, lat?, lng?
Member  memberNo  →  lat?, lng?

Two consequences worth knowing:

  • Correcting a camp site is one edit. Moving a group means editing that one Group row, not every tag pointing at it.
  • References are soft, not foreign keys. A tag can be registered before its group or member has been imported. Until then the tag resolves with resolved: false and shows the bare number; it links up automatically once the lookup row arrives. Deleting a group or member does not delete tags — they just become unresolved, and the UI reports how many.

Coordinates prefer the group's camp site, falling back to the member's own position when the group has none. coordinateSource says which was used.

Local development

Requires Docker (for PostgreSQL) and the j26 CLI for the shared-origin proxy.

cp .env.local.template .env.local     # then fill in the values
docker compose up -d                  # PostgreSQL on :5432
pnpm install
pnpm db:migrate                       # apply migrations
pnpm dev                              # http://0.0.0.0:3002

Because the app must be served under its base path with the shell's auth cookie on the same origin, use the CLI rather than hitting :3002 directly. Add a local service entry to your .j26.local.yaml:

localServices:
  tags:
    name: tags
    path: /_services/tags
    port: 3002
    rewritePath: false

then j26 up and open https://local.j26.se/_services/tags.

Commands

pnpm dev            # Dev server (port 3002, host 0.0.0.0)
pnpm build          # Production build (Nitro bundle in .output/)
pnpm test           # Vitest
pnpm check          # Biome lint + format
pnpm typecheck      # tsc --noEmit

pnpm db:generate    # Regenerate the Prisma client after schema changes
pnpm db:migrate     # Create + apply a migration
pnpm db:studio      # Prisma Studio
pnpm db:reset       # Drop and re-migrate

Environment

Variable Purpose
DATABASE_URL PostgreSQL connection string
KEYCLOAK_DISCOVERY_URL OIDC discovery document; supplies the issuer and JWKS URL used to verify JWTs
TAGS_API_KEY Single static key for the machine API (read and register)

Machine API

Authenticate with either header:

Authorization: Bearer <TAGS_API_KEY>
X-API-Key: <TAGS_API_KEY>

GET /_services/tags/api/tags

group, lat and lng are resolved from the lookup tables, not stored on the tag. resolved: false means a referenced group or member has not been imported yet.

{
  "tags": [
    {
      "tagId": "04A21B",
      "groupId": "1234",
      "memberNo": "555001",
      "group": "Bromma Scoutkår",
      "lat": 57.7089,
      "lng": 11.9746,
      "coordinateSource": "group",
      "resolved": true,
      "unresolved": [],
      "updatedAt": "2026-07-27T09:00:00.000Z"
    }
  ]
}

GET /_services/tags/api/tags/:tagId

The same object, or 404 {"error":"not_found"}.

POST /_services/tags/api/tags

Register a new tag. tagId is required, plus at least one of groupId and memberNo. The group name and coordinates are not accepted here — they belong to the group and member records.

curl -X POST https://app.jamboree.se/_services/tags/api/tags \
  -H "X-API-Key: $TAGS_API_KEY" -H 'Content-Type: application/json' \
  -d '{"tagId":"04:A2:1B","groupId":"1234","memberNo":"555001"}'

201 with the created tag, resolved — so a resolved: false in the response tells you the tag was stored but its group/member is not in the lookup tables yet. That is allowed on purpose; registration never blocks on import order.

This is not an upsert — re-registering an existing id returns 409 {"error":"already_exists","tagId":"04A21B"} rather than overwriting, since that is far more often a mistake than an intent to replace. Use the admin UI to edit an existing tag.

Tags created this way are attributed to Maskin-API in the admin UI, since there is no Keycloak user on this path.

Errors

Machine clients should match on the error code, not the HTTP body prose.

Status Code Meaning
400 invalid_json, invalid_body Body was not a JSON object
400 invalid_tag_id Missing or unusable tagId
400 missing_reference Neither groupId nor memberNo given
400 invalid_group_id, invalid_member_no Malformed reference number
400 moved_field Sent group, lat or lng — see fields in the body
401 Missing or wrong API key
404 not_found No such tag
405 method_not_allowed See the Allow header
409 already_exists That tag id is already registered

moved_field exists so a client still sending a group name and coordinates fails loudly instead of believing it had stored them.

Tag ids are normalised (uppercased, separators stripped), so 04:a2:1b, 04-A2-1B and 04a21b all refer to the same tag on both read and write.

The Keycloak cookie does not open these endpoints, and the API key does not open the UI or /api/me.

Roles

Declared as code in j26-keycloak under the j26-tags client, and read from resource_access["j26-tags"].roles.

Role Grants
admin Everything
tags:write Create/edit/delete tags, groups and members, and run imports. Implies lookup.
tags:read Look up and search only

A user with none of these gets 401 from /api/app-config, which hides the tool from the shell navigation entirely.

CSV import formats

Three separate imports, one per table. All of them: header row required, column order free, , or ; as the delimiter, and , accepted as a decimal separator. Blank, -, null and n/a all mean "not set".

Encoding is handled for you. Uploads are decoded by sniffing the bytes — BOM first, then strict UTF-8, falling back to Windows-1252 (what desktop Excel's plain "CSV" export writes). UTF-16 works too. So a file saved from Excel any of the usual ways imports with å ä ö intact, and the form reports which encoding it used when it isn't UTF-8.

The one case that can't be rescued is a file whose characters were already destroyed before upload — if å arrives as a literal ? or , the information is gone. The import form detects this and tells the operator to re-save from Excel as CSV UTF-8 (comma delimited) rather than plain CSV. The downloadable samples all carry a UTF-8 BOM so Excel opens them correctly in the first place.

Each is previewed first and committed as a single transaction — if any row is invalid, nothing is written. Ready-made samples live in public/ and are linked for download from each import page.

TagstagId required, plus at least one of groupId/memberNo per row:

tagId;groupId;memberNo
04:A2:1B;1234;555001
CAFE0001;1234;
DEADBEEF;;901234

GroupsgroupId and name required, coordinates optional:

groupId;name;lat;lng
1234;Bromma Scoutkår;57,7089;11,9746
5678;Lund Scoutkår;;

MembersmemberNo required, coordinates optional:

memberNo;lat;lng
555001;55,7047;13,1910
901234;;

Rows referencing a group or member that hasn't been imported yet are imported anyway and flagged in the preview — import order doesn't matter, and the tags link up once the lookup rows arrive.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages