A three-layer TypeScript application that operationalises the Grounded Theory Curation Model (Adorjan, 2025). Researchers can create investigations, register their team, define the research process, build a grounded theory from codes and categories, and curate every artefact produced along the way.
The data model mirrors the PlantUML diagrams from the thesis
(../Material/Diagrams/v2/) one-to-one, plus the minimum scaffolding needed
to make the model usable in practice (Researcher accounts, ResearcherGroups,
and a top-level Research container).
- Quick start
- Architecture
- Domain model
- Authentication & authorization
- Backend
- Frontend
- Configuration
- Development without Docker
- Project layout
- Verification
- Troubleshooting
Requirements
- Docker 24+ with the Compose plugin (
docker compose ...) - ~1 GB of free disk for images and the Postgres volume
Boot the stack
cd "Taller de Iniciación Académica/app"
./app upThe first run takes ~1–2 minutes (image pulls + Prisma client generation). Subsequent runs are seconds.
| URL | What |
|---|---|
| http://localhost:5173 | Frontend (React SPA) |
| http://localhost:3000 | Backend REST API base |
| http://localhost:3000/api/docs | Swagger UI for the full API |
| http://localhost:3000/health | Liveness + DB probe |
Other commands
./app up --reset # drop all data and start fresh
./app up --seed # load the Argonauts of the Western Pacific demo dataset
./app up --reset --seed # clean DB + load the demo (recommended for first boot)
./app down # stop the stack (data is preserved)
./app logs backend # follow logs from a single service
./app help # all optionsThe script auto-creates .env from .env.example on first run.
The AI features can run against Watsonx.ai (IBM cloud, the default) or Ollama (local models). You pick the provider when you start the stack:
./app up # Watsonx.ai (default — nothing to install locally)
./app up --provider ollama # Ollama, running models on your machine-
Watsonx (default). Set
AI_ENABLED=1and yourWATSONX_API_KEY/WATSONX_PROJECT_IDin.env. No Ollama containers are built or started, so a modest machine has zero extra overhead. -
Ollama (local).
--provider ollamaenables AI, starts a localollamacontainer, and a one-shotollama-pullcontainer that downloads the default Granite model (granite3.3:8b) the first time — this can take a while and several GB. Track the download with:./app logs ollama-pull
Granite is used for every AI use case by default; override with
OLLAMA_MODEL_IDin.env. Requirements: enough RAM/disk for an 8B model (~6–8 GB). The model is cached in a Docker volume, so later starts are fast.
Either way the active provider and its models are reported at
GET /api/v1/ai/status, and the UI's model selectors adapt automatically.
┌────────────────────────────────────────────────────────────────┐
│ Browser ─▶ http://localhost:5173 │
│ ──────── │
│ │
│ ┌──────────────┐ /api/* (proxied) ┌──────────────┐ │
│ │ Frontend │ ──────────────────▶ │ Backend │ │
│ │ React SPA │ │ NestJS API │ │
│ │ nginx │ │ Node 20 │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ Prisma │
│ ▼ │
│ ┌──────────────┐ │
│ │ Postgres 16 │ │
│ └──────────────┘ │
└────────────────────────────────────────────────────────────────┘
| Layer | Stack |
|---|---|
| Database | PostgreSQL 16 |
| Backend | NestJS 10 · Prisma 5 · JWT (passport-jwt) · class-validator · Swagger |
| Frontend | React 18 · Vite · TypeScript · Tailwind CSS · TanStack Query · React Router · lucide-react |
| Container | Docker Compose · multi-container (db, backend, frontend) |
The frontend's nginx reverse-proxies /api/* and /health to the backend, so
the browser only ever talks to a single origin.
The schema (backend/prisma/schema.prisma) encodes Adorjan's GTA-DC model
verbatim and adds three new entities to make it usable as software.
| PlantUML package | Models | Notes |
|---|---|---|
01_Artefact |
Artefact (abstract base) |
Class-table inheritance root |
02_ResearchContext |
ResearchContext, Field, Participant, TheoreticalFramework, Memo, Finding, Bibliography, ResearchQuestion, Method, Protocol, Tool |
Field is 1..n in Context; Method has many Protocols and many Tools |
03_GroundedTheory |
Theory, Category, Code, CategoryRelation, CodeRelation |
M2M self-relations for "related categories/codes" |
04_ResearchTeam |
ResearchTeam, ConsensusCriteria |
Researcher participates via ResearchTeamMembership |
05_ResearchProcess |
Process, Stage, Activity, ActivityTool, ActivityInputArtefact, ActivityOutputArtefact |
Activity links to many tools and many input/output artefacts |
| Model | Purpose |
|---|---|
Researcher |
The PlantUML <<Actor>> Researcher extended with email, passwordHash, displayName, isAdmin. This is the user account. |
ResearcherGroup + ResearcherGroupMembership |
Institutional grouping (e.g. "ORT Grounded Theory Lab"). Many-to-many with Researcher. |
Research |
Top-level container that bundles a ResearchContext, a Theory, a Process, and a ResearchTeam. Has a single ownerId (a Researcher). One Researcher can own / participate in many Researches. |
The PlantUML model marks 14 classes as <<Artefact>> (Theory, Category, Code,
ResearchContext, Field, TheoreticalFramework, Memo, Finding, Bibliography,
ResearchQuestion, Method, Tool, Protocol, ConsensusCriteria). They share
hashID, name, media, access, status, content, anonymized, responsibleId, etc.
Rather than duplicating those columns 14 times, the schema uses class-table inheritance:
- A base
Artefacttable holds every shared field plus akinddiscriminator (ArtefactKindenum). - Each subtype gets its own table whose primary key is also a foreign key
back to
Artefact.id(cascade delete). The subtype table holds only the fields specific to that subtype (e.g.Theory.theoryType,ResearchQuestion.identifier,Tool.referenceURL).
This buys two things:
- Strong typing per subtype — Prisma still sees
Theory.theoryType,Code.categoryId, etc. - Uniform queries —
GET /researches/:id/artefactshits a single base table and returns every artefact regardless of subtype, which the frontend uses for the "All artefacts" tab.
Service code maintains the invariant inside transactions: every artefact create/delete touches both the base row and the subtype row atomically.
TypeOfMedia : video | text | audio | dataset | software
TypeOfAccess : public | private
TypeOfStatus : pending | inprogress | complete
TypeOfRole : Junior | Senior (academic role on Researcher)
TypeOfVote : unanimous | majority | consensus (ConsensusCriteria)
TypeOfTheory : classic | constructivist | straussian
TypeOfMethod : qualitative | quantitative | mixed
ArtefactKind : THEORY, CATEGORY, CODE, RESEARCH_CONTEXT, FIELD,
THEORETICAL_FRAMEWORK, MEMO, FINDING, BIBLIOGRAPHY,
RESEARCH_QUESTION, METHOD, TOOL, PROTOCOL,
CONSENSUS_CRITERIA
POST /api/v1/auth/register— creates aResearcherand returns{ researcher, token }.POST /api/v1/auth/login— returns{ researcher, token }.GET /api/v1/auth/me— returns the current researcher.
The token is a JWT signed with JWT_SECRET (default 7d lifetime). The
frontend stores it in localStorage under gtadc_token and an Axios-like
fetch wrapper attaches it to every request.
A global JwtAuthGuard protects every route. Public routes are explicitly
opted in with the @Public() decorator (currently: auth/register,
auth/login, health).
Three orthogonal layers:
| Layer | Mechanism |
|---|---|
| System admin | Researcher.isAdmin boolean. Admins can create/edit any ResearcherGroup and act as the owner of any Research. |
| Per-research owner | Research.ownerId. The owner can edit/delete the research and any of its artefacts. Enforced by a per-service helper ResearchAccessService.assertOwnable(...). |
| Per-research membership | A researcher who is on the ResearchTeam (via ResearchTeamMembership) can read everything in that research but cannot mutate anything (unless they are also owner or admin). |
Academic role (Junior / Senior from the GTA-DC model) is not used for
authorization — it's metadata for the team page.
When a Research is created, the backend transactionally:
- Creates the
Researchrow withownerId = currentUser.id. - Creates an empty
ResearchTeam1-1 with that research. - Adds the owner as the first team member.
So a freshly-created research is always immediately editable by its owner and appears in their dashboard.
backend/src/
├── main.ts # bootstrap, validation, Swagger
├── app.module.ts # wires every feature module
├── prisma/ # PrismaService (global)
├── common/ # ResearchAccessService, base DTOs
├── auth/ # JWT strategy, guards, /auth/* endpoints
├── researcher/ # /researchers (user CRUD)
├── researcher-group/ # /researcher-groups
├── research/ # /researches (top-level container)
├── research-team/ # team members + ConsensusCriteria
├── research-context/ # Context + Field + Participant
├── theoretical-framework/ # Framework + Memo, Finding, Bibliography,
│ # ResearchQuestion, Method, Protocol, Tool
├── grounded-theory/ # Theory + Category + Code (+ M2M relations)
├── research-process/ # Process + Stage + Activity (+ M2M)
├── artefact/ # cross-cutting GET /researches/:id/artefacts
└── health/ # GET /health
The five PlantUML packages (research-context, theoretical-framework,
grounded-theory, research-team, research-process) each become a NestJS
module of the same name, so the directory structure visibly mirrors the model.
The full API is documented at http://localhost:3000/api/docs (Swagger UI, 61 endpoints). Highlights:
| Group | Notable routes |
|---|---|
auth |
POST /auth/register, POST /auth/login, GET /auth/me |
researchers |
GET/PATCH/DELETE /researchers[/:id] |
researcher-groups |
GET/POST/PATCH/DELETE /researcher-groups, POST/DELETE …/members |
researches |
GET/POST/PATCH/DELETE /researches[/:id] |
team |
…/team, …/team/members, …/team/consensus-criteria, /consensus-criteria/:id |
context |
…/context, /contexts/:id/fields, /fields/:id/participants |
framework |
/contexts/:id/framework and CRUD for memos, findings, bibliographies, research-questions, methods, protocols, plus method↔tool linking |
tools |
/researches/:id/tools, /tools/:id |
grounded-theory |
/researches/:id/theory, /theories/:id/categories, /categories/:id/codes, plus /relations for self-M2M |
process |
/researches/:id/process, /processes/:id/stages, /stages/:id/activities, plus tools/inputs/outputs M2M |
artefact |
GET /researches/:id/artefacts?kind=… (uniform across all subtypes) |
- DTOs use
class-validatorwithwhitelist + forbidNonWhitelistedso unknown fields are rejected early. ValidationPipeis global. Validation errors return HTTP 400 with a human-readable message array.- Auth failures return 401 (token missing/expired/invalid).
- Authorization failures return 403.
- Missing entities return 404.
- All errors share NestJS's standard envelope:
{ statusCode, message, error }.
- Schema lives in
backend/prisma/schema.prisma. - Initial migration is committed under
backend/prisma/migrations/. - On container start the backend runs
prisma migrate deploy, then starts Nest. WithRESET=1it runsprisma migrate reset --force --skip-seedfirst, wiping the database before re-applying migrations.
backend/prisma/schema.prisma— encodes the entire model.backend/src/common/artefact-base.dto.ts—ArtefactBaseDtoshared by every artefact create-DTO;pickArtefactFields()andpickArtefactUpdateFields()are the helpers that feed the Prismacreate/updatecalls in subtype services.backend/src/common/research-access.ts—assertReadable()andassertOwnable()enforce per-research authorization. Used by every feature service.backend/src/auth/research-owner.guard.ts— per-route guard for research-scoped mutating endpoints (kept available for future use; today most endpoints callassertOwnablefrom inside the service so the rule is next to the data).
- React 18 with React Router v6.
- TanStack Query for all server state. Mutations call
qc.invalidateQueries(...)and the relevant lists refresh automatically. - Tailwind CSS with a custom
ink(slate-derived) andaccent(violet) palette, plusInterfor body andSource Serif Profor headings — a modern academic look. - lucide-react for icons.
- A small in-house design system (
src/components/):Button,Input/Textarea/Select/Field,Card,Modal,ConfirmDialog,Badge/StatusBadge,Avatar,EmptyState,Tabs,Toast,PageHeader,TopBar,Layout.
| Path | Page |
|---|---|
/login |
Sign in (with hero quote from Malinowski's Argonauts) |
/register |
Create account |
/ |
Dashboard (stats, recent researches, your groups) |
/researches |
All researches with create modal |
/researches/:id |
Research detail with six sticky tabs |
/groups |
Researcher groups (admin manages, others view) |
/profile |
Edit your own account |
The Research detail page is the primary workspace. Tabs are sticky and update their counts live:
| Tab | What it manages |
|---|---|
| Overview | Title/description, owner, component status (which of Context/Theory/Process/Team are configured), danger zone (delete) |
| Context | ResearchContext (1-1), Field (1..n) with Participant chips, TheoreticalFramework (1-1) with five inline blocks for ResearchQuestion, Method (with nested Protocol rows), Memo, Finding, Bibliography, plus a research-scoped Tool registry |
| Team | Members with role/owner badges, add/remove. Consensus criteria as Artefacts with voting type |
| Process | Process (1-1), Stage (1..n), Activity (1..n) with status, dates, responsible researcher (drawn from team) |
| Grounded Theory | Theory with variant (classic/constructivist/straussian), Category (1..n), Code (n..n inside categories) |
| All artefacts | Uniform read-only list pulled from GET /researches/:id/artefacts, filterable by ArtefactKind |
AuthProvider(src/auth/AuthContext.tsx) reads the JWT fromlocalStorageon mount, callsGET /auth/me, and exposes{ user, login, register, logout, refresh }.<ProtectedRoute>wraps the authenticated app. Unauthenticated requests are redirected to/loginwithstate.fromso post-login lands you back where you started.- Any 401 response from the API client clears the token and triggers a redirect on the next render.
A consistent pattern across the codebase:
const qc = useQueryClient();
const toast = useToast();
const mut = useMutation({
mutationFn: () => api.something.create(...),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['something', parentId] });
toast.success('Saved');
},
onError: (err) =>
toast.error(err instanceof ApiError ? err.message : 'Could not save'),
});The Compose stack reads app/.env (auto-bootstrapped from .env.example).
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_USER |
gtadc |
DB user |
POSTGRES_PASSWORD |
gtadc |
DB password |
POSTGRES_DB |
gtadc |
DB name |
POSTGRES_PORT |
5432 |
Host port for Postgres |
JWT_SECRET |
change-me-in-production |
Set this in any real deployment |
JWT_EXPIRES_IN |
7d |
JWT lifetime |
BACKEND_PORT |
3000 |
Host port for the backend |
FRONTEND_PORT |
5173 |
Host port for the frontend |
RESET |
0 |
Set to 1 (or use ./app up --reset) to wipe DB |
SEED |
0 |
Set to 1 (or use ./app up --seed) to load the Argonauts demo |
AI_ENABLED |
0 |
Master switch for all AI features |
AI_PROVIDER |
watsonx |
Active provider: watsonx or ollama (set by ./app up --provider) |
WATSONX_API_KEY |
(empty) | IBM Cloud API key (Watsonx) |
WATSONX_PROJECT_ID |
(empty) | Watsonx project id |
WATSONX_MODEL_ID |
ibm/granite-4-h-small |
Fallback model when a request sends no override |
WATSONX_URL |
https://us-south.ml.cloud.ibm.com |
Watsonx region endpoint |
OLLAMA_BASE_URL |
http://ollama:11434 |
Ollama server URL (in-compose service) |
OLLAMA_MODEL_ID |
granite3.3:8b |
Granite model used for every AI use case |
OLLAMA_PORT |
11434 |
Host port for the Ollama service |
The backend image also reads its own .env if you run it locally outside
Docker (see Development without Docker).
If you want hot reload + fast iteration on either layer:
cd backend
npm install
cp .env.example .env # tweak DATABASE_URL if needed
npx prisma migrate dev # run migrations (against a Postgres you control)
npm run start:dev # nest start --watch on http://localhost:3000For the database, the easiest path is to leave ./app up running for just
the db service:
docker compose up -d db
DATABASE_URL='postgresql://gtadc:gtadc@localhost:5432/gtadc?schema=public' npm run start:devcd frontend
npm install
npm run dev # vite on http://localhost:5173The Vite dev server proxies /api and /health to
process.env.VITE_API_PROXY ?? 'http://localhost:3000' (see
vite.config.ts), so the frontend can talk to either the dockerised backend
or a npm run start:dev backend without changes.
cd backend
docker compose -f ../docker-compose.yml up -d db # if not already running
DATABASE_URL='postgresql://gtadc:gtadc@localhost:5432/gtadc?schema=public' \
npx prisma migrate dev --name describe-the-changeCommit the new folder under backend/prisma/migrations/. On the next
./app up, the backend container will run prisma migrate deploy
automatically.
app/
├── README.md ← this file
├── app # entrypoint script
├── docker-compose.yml
├── .env.example
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ ├── tsconfig.json / tsconfig.build.json
│ ├── nest-cli.json
│ ├── prisma/
│ │ ├── schema.prisma ← the entire data model
│ │ └── migrations/
│ └── src/
│ ├── main.ts / app.module.ts
│ ├── prisma/ # PrismaService (global)
│ ├── common/ # base DTOs, ResearchAccessService
│ ├── auth/ # JWT, guards, decorators
│ ├── researcher/ # /researchers
│ ├── researcher-group/ # /researcher-groups
│ ├── research/ # /researches
│ ├── research-team/ # team members + consensus
│ ├── research-context/ # context + field + participant
│ ├── theoretical-framework/ # framework + 7 sub-artefacts
│ ├── grounded-theory/ # theory + category + code
│ ├── research-process/ # process + stage + activity
│ ├── artefact/ # cross-cutting list endpoint
│ └── health/ # GET /health
└── frontend/
├── Dockerfile
├── nginx.conf # SPA fallback + API proxy
├── package.json
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
├── vite.config.ts
├── tailwind.config.js
├── postcss.config.js
├── index.html
└── src/
├── main.tsx / App.tsx / index.css
├── lib/ # api client + types + query
├── auth/ # AuthProvider + ProtectedRoute
├── components/ # design system primitives
├── pages/
│ ├── LoginPage.tsx / RegisterPage.tsx
│ ├── DashboardPage.tsx
│ ├── ResearchesPage.tsx
│ ├── ResearchDetailPage.tsx # tab orchestrator
│ ├── ResearcherGroupsPage.tsx
│ └── ProfilePage.tsx
└── features/
└── research/ # one file per detail tab
├── OverviewTab.tsx
├── ContextTab.tsx
├── TeamTab.tsx
├── ProcessTab.tsx
├── GroundedTheoryTab.tsx
└── ArtefactsTab.tsx
A clean smoke test from a freshly cloned repo:
cd "Taller de Iniciación Académica/app"
./app up --resetThen open http://localhost:5173, register a new account, create a Research, and walk through the six tabs:
- Overview — verify the auto-created team has you as the only member
- Context — Create context → add a Field → add a few Participants → create the Theoretical Framework → add a Research Question, a Method with a Protocol, a Memo
- Team — invite a second account (register another in another browser) → confirm membership is visible to both
- Process — Create process → add a Stage → add an Activity with a responsible researcher
- Grounded Theory — Create theory → add a Category → add a Code
- All artefacts — confirm each artefact you created shows up here, and
that the
kindfilter chips work
For pure-API verification, the Swagger UI at http://localhost:3000/api/docs lets you exercise every endpoint with a token pasted into the Authorize box.
docker: unknown command: docker compose
You have Docker but not the Compose v2 plugin. On macOS, the Homebrew
formula docker-compose ships the plugin — symlink it so the CLI finds it:
mkdir -p ~/.docker/cli-plugins
ln -sf /opt/homebrew/lib/docker/cli-plugins/docker-compose ~/.docker/cli-plugins/docker-compose
docker compose version # should now print 2.xPort 3000 / 5173 / 5432 already in use
Edit .env and set BACKEND_PORT, FRONTEND_PORT or POSTGRES_PORT, then
./app down && ./app up.
The backend container restarts in a loop
Tail the logs: ./app logs backend. The most common causes are the DB not
being healthy yet (Compose waits, but if you killed Postgres mid-flight the
restart can race) and an out-of-date Prisma client. Try ./app down -v && ./app up --reset.
"Email already registered" on the very first registration
Either the DB has stale data from a previous run, or you registered twice.
Use ./app up --reset to start over.
Frontend shows "Loading…" forever
Check the browser network tab. If GET /auth/me is returning 401, your
token is invalid or the backend is unreachable; clear localStorage and
reload, then sign in again.
What does --seed do?
It loads a fully populated demo Research based on Bronisław Malinowski's
Argonauts of the Western Pacific (1922). Three Researcher accounts, an
institutional group, ~50 artefacts across every ArtefactKind, a four-stage
process, and a five-category theory are created. See DEMO.md for a
self-guided tour of the seeded data, and the seed source at
backend/src/seed/malinowski.ts for the exact mapping from the ethnography
to GTA-DC entities. Re-running --seed always wipes and re-creates the
seeded research and users; other accounts you have added are preserved.
- Adorjan, A. (2025). Modelling the Curation of Grounded Theory Research
Artefacts. Doctoral thesis, Universidad ORT Uruguay / PEDECIBA.
Source diagrams:
../Material/Diagrams/v2/. Summary:../Material/Resumen_Tesis_Adorjan.md. - Glaser, B. & Strauss, A. (1967). The Discovery of Grounded Theory.
- Charmaz, K. (2006). Constructing Grounded Theory.
- Malinowski, B. (1922). Argonauts of the Western Pacific. — used as the worked-example case study (and the quote on the login screen).