Backend REST service for team note-taking, built with Django, Django REST Framework, and PostgreSQL.
- Decision: I implemented multi-tenancy at the row level via
TeamandTeamMembershipmodels, enforced through centralized QuerySet selectors (apps/notes/selectors.py) and granular RBAC permissions. - Why: For small-to-medium teams, dedicated PostgreSQL schemas or separate databases introduce high operational overhead, complicated migration workflows, and connection pool fragmentation. Row-level scoping combined with composite database indexes (
[team, is_pinned, -created_at],[author, team, deleted_at]) provides sub-millisecond query performance, strict tenant isolation, and simple migrations. - Tradeoff: Application logic must guarantee that all queries are scoped to the authenticated user's memberships. I mitigated the risk of cross-tenant data leaks by centralizing query construction in dedicated selectors rather than scattering raw
.filter()calls across views.
- Decision: Every note maintains a monotonically increasing integer
versionfield. When updating a note (PUT/PATCH), the client must provideexpected_version. The update executes atomically; if the current database version does not matchexpected_version, the server aborts the write and returns409 Conflict. - Why: In shared team workspaces, concurrent edits are a primary failure mode. The naive "Last-Write-Wins" pattern silently overwrites data when two teammates edit simultaneously. Pessimistic locking (row-level locks) creates contention bottlenecks and breaks stateless REST semantics if a client disconnects mid-edit. OCC provides data integrity while keeping the API completely stateless.
- Tradeoff: Clients must handle
409 Conflictby fetching the latest revision and prompting the user to resolve or merge differences.
- Decision: Search is implemented using PostgreSQL's native
SearchVectorField,SearchQuery(search_type="websearch"),SearchRank, and aGINindex (GinIndex(fields=['search_vector'])). - Why: Running external search clusters (like Elasticsearch or Meilisearch) adds operational complexity, sync latency, and potential split-brain inconsistency. PostgreSQL full-text search provides linguistic stemming, relevance ranking, and web-style search operators (e.g. quotes for exact phrase, minus for exclusion) with zero replication lag and full ACID transactional guarantees.
- Tradeoff: High-volume text writes require vector re-indexing during updates. I keep indexing synchronized during note mutations within the service layer.
- Decision: I decoupled business logic and query orchestration into
services.py(atomic mutations, OCC validation, revision snapshotting) andselectors.py(tenant-scoped data access), keeping models and ViewSets thin. - Why: Fat models create bloated domain objects tightly coupled to Django ORM internals, while fat views make code difficult to test without mocking HTTP request/response lifecycles. Isolating business transactions in plain Python functions ensures complete unit testability, deterministic database transactions (
@transaction.atomic), and reusable business workflows. - Tradeoff: Adds a layer of indirection compared to standard Django CRUD scaffolding, but significantly improves maintainability and test clarity.
- Decision: Notes can exist either as personal notes (
team = None) or team notes (team = Team). Personal notes are strictly private to the author, while team notes inherit the team's RBAC hierarchy. An explicit action endpoint (POST /api/v1/notes/{id}/share-to-team/) enables moving or promoting a personal note into a team workspace. - Why: Users frequently start drafting thoughts privately before sharing them with colleagues. Supporting personal notes out of the box eliminates the need for users to create dummy "personal teams", simplifying the user onboarding experience.
- Tradeoff: Selectors must handle both personal notes and team notes when constructing unified search or aggregate listings.
- JWT Authentication & RFC 6750 Bearer Standard (
djangorestframework-simplejwt):- Currently, the service uses DRF's built-in token authentication (
Authorization: Token <token>) backed by the database. In a higher-scale system, I would migrate to the RFC 6750 standardAuthorization: Bearer <jwt_token>format with short-lived, cryptographically signed access tokens and sliding refresh tokens. This eliminates a database query on every authenticated request while enabling seamless multi-service SSO and token revocation via Redis blacklisting.
- Currently, the service uses DRF's built-in token authentication (
- Asynchronous Background Processing (Celery / Redis / Valkey):
- Move search vector re-indexing, export generation, and team invitation emails to background workers.
- Real-Time Collaborative Editing (CRDTs over WebSockets):
- While OCC prevents lost updates for asynchronous REST workflows, real-time simultaneous co-authoring (similar to Google Docs or Notion) would benefit from CRDTs (Conflict-free Replicated Data Types via Yjs) over Django Channels / WebSockets.
- Fine-Grained Resource Permissions (Object-Level ACLs):
- Extend the RBAC model to support per-note permission overrides (e.g. granting specific team members edit access to an otherwise read-only note).
- Rich Content Diffing and Semantic Search:
- Integrate structured JSON rich-text (ProseMirror / Block-based schema) and vector embeddings (pgvector) to provide hybrid keyword + semantic similarity search.
- Rate Limiting and Abuse Prevention:
- Configure Redis-backed rate limiting on authentication and search endpoints to prevent enumeration and denial-of-service vectors.
The environment is containerized using Docker Compose and managed with uv.
Note on
.env.local:.env.localis intentionally committed to this repository to provide an instant, zero-friction local setup for reviewers. In production environments, credentials are provided via external secret managers.
docker compose -f local.yml up --buildThe database will initialize, migrations will apply automatically on startup, and the API service will be accessible at:
- API Base:
http://localhost:8000 - Interactive Swagger UI:
http://localhost:8000/api/docs/ - ReDoc Documentation:
http://localhost:8000/api/redoc/ - OpenAPI 3.0 Schema:
http://localhost:8000/api/schema/ - Django Admin:
http://localhost:8000/admin/ - Health Check:
http://localhost:8000/health/
To inspect, moderate, or manage data in the Django Admin interface (http://localhost:8000/admin/):
# Interactive prompt:
docker compose -f local.yml exec django python manage.py createsuperuser
# Or non-interactive one-liner (Username: admin, Password: adminpassword123):
docker compose -f local.yml exec -e DJANGO_SUPERUSER_PASSWORD=adminpassword123 django python manage.py createsuperuser --noinput --username admin --email admin@example.comdocker compose -f local.yml exec django pytestdocker compose -f local.yml exec django ruff check .
docker compose -f local.yml exec django ruff format --check .All protected endpoints require an Authorization: Token <token> header.
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
POST |
/api/v1/auth/register/ |
Register a new user and receive auth token | No |
POST |
/api/v1/auth/token/ |
Obtain auth token with username and password | No |
GET |
/api/v1/auth/me/ |
Retrieve current user profile | Yes |
PATCH |
/api/v1/auth/me/ |
Update current user profile | Yes |
POST |
/api/v1/auth/logout/ |
Invalidate and revoke current auth token | Yes |
| Method | Endpoint | Description | Access Rule |
|---|---|---|---|
GET |
/api/v1/teams/ |
List teams current user belongs to | Member |
POST |
/api/v1/teams/ |
Create team workspace (creator becomes OWNER) | Authenticated |
GET |
/api/v1/teams/{id}/ |
Retrieve team details | Member |
PATCH |
/api/v1/teams/{id}/ |
Update team details (name, description) | Owner / Admin |
DELETE |
/api/v1/teams/{id}/ |
Delete team workspace | Owner |
GET |
/api/v1/teams/{id}/members/ |
List team members with roles | Member |
POST |
/api/v1/teams/{id}/members/add/ |
Invite/add a user with a role | Owner / Admin |
PATCH |
/api/v1/teams/{id}/members/{user_id}/role/ |
Update member role | Owner / Admin |
DELETE |
/api/v1/teams/{id}/members/{user_id}/ |
Remove member or leave team | Owner / Admin / Self |
| Method | Endpoint | Description | Access Rule |
|---|---|---|---|
GET |
/api/v1/notes/ |
List accessible notes (supports ?team=, ?personal=true, ?tag=, ?is_pinned=) |
Authenticated |
POST |
/api/v1/notes/ |
Create personal note (team_id: null) or team note |
Authenticated (Non-Viewer) |
GET |
/api/v1/notes/{id}/ |
Retrieve note details and content | Author or Team Member |
PUT |
/api/v1/notes/{id}/ |
Update note (requires expected_version for OCC) |
Author or Team Member (Non-Viewer) |
DELETE |
/api/v1/notes/{id}/ |
Soft-delete note (move to trash) or ?hard=true |
Author or Team Admin/Owner |
GET |
/api/v1/notes/trash/ |
List soft-deleted notes in trash | Author or Team Member |
POST |
/api/v1/notes/{id}/restore/ |
Restore soft-deleted note from trash | Author or Team Admin/Owner |
GET |
/api/v1/notes/{id}/history/ |
View revision history snapshots | Author or Team Member |
POST |
/api/v1/notes/{id}/revert/ |
Revert note content to a prior version number | Author or Team Member (Non-Viewer) |
POST |
/api/v1/notes/{id}/share-to-team/ |
Promote personal note into team workspace | Author |
GET |
/api/v1/notes/search/?q={query} |
Ranked full-text search across accessible notes | Authenticated |
| Method | Endpoint | Description | Access Rule |
|---|---|---|---|
GET |
/api/v1/notes/tags/ |
List tags accessible to user (?team={id}) |
Authenticated |
POST |
/api/v1/notes/tags/ |
Create a tag (personal or team-scoped) | Authenticated |
| Permission | Owner | Admin | Member | Viewer |
|---|---|---|---|---|
| View Team Notes | Yes | Yes | Yes | Yes |
| Create Team Notes | Yes | Yes | Yes | No |
| Edit Shared Team Notes | Yes | Yes | Yes | No |
| View Private Notes in Team | Yes | Yes | Author Only | Author Only |
| Soft-Delete Any Note | Yes | Yes | Author Only | No |
| Add / Remove Members | Yes | Yes | No | No |
| Assign / Demote Owner Role | Yes | No | No | No |
| Delete Team Workspace | Yes | No | No | No |
The test suite is modularized directly inside each domain application, separating service-level unit tests from HTTP/API integration tests:
test_health.py: Health check and basic service liveness.test_models.py:SoftDeleteModelandSoftDeleteQuerySetunit testing (.active(),.deleted(),.soft_delete(),.restore()).
test_api.py: User registration, duplicate username rejection, password confirmation validation, token generation, profile update (PATCH /me/), token revocation upon logout, and unauthenticated boundary enforcement.
test_services.py: Unit tests for team creation, member additions, role hierarchy invariants, and sole-owner protection.test_api.py: Integration tests for team workspaces, member roster listing, and non-existent user handling.test_permissions.py: Full RBAC permission matrix verification across all roles (OWNER,ADMIN,MEMBER,VIEWER,OUTSIDER).
test_services.py: Unit tests for note creation, version 1 snapshotting, Optimistic Concurrency Control conflicts, and historical revision reversion.test_tags.py: Personal tags, team-scoped tags, tag list scoping, and unique name constraints.test_search.py: Ranked PostgreSQL full-text search matching across title/body, tag filtering, and strict search tenant isolation (ensuring foreign team notes are never returned).test_api.py: Integration tests for personal vs. team notes, 409 Conflict handling, soft-delete and restore lifecycle, revision history endpoints, and personal-to-team note promotion.test_permissions.py: Exhaustive permission matrix for personal notes privacy, shared team notes, private team notes, and moderation delete rules.test_edge_cases.py: Pagination structure, pinned note filtering, missingexpected_versionvalidation, hard delete permanent purging, invalid reversion versions, and custom error JSON envelope structure.
Follow this sequence to test all core features from terminal or API clients:
# Register Alice (Team Owner)
curl -X POST http://localhost:8000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "alice@example.com", "password": "Password123!", "password_confirm": "Password123!", "first_name": "Alice", "last_name": "Smith"}'
# Register Bob (Team Member)
curl -X POST http://localhost:8000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"username": "bob", "email": "bob@example.com", "password": "Password123!", "password_confirm": "Password123!", "first_name": "Bob", "last_name": "Jones"}'curl -X POST http://localhost:8000/api/v1/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "Password123!"}'
# Response returns: {"token": "<ALICE_TOKEN>", "user": {...}}# Create Team Workspace (Alice becomes OWNER)
curl -X POST http://localhost:8000/api/v1/teams/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "Engineering Core", "description": "Backend architecture team"}'
# Response returns: {"id": "<TEAM_ID>", "name": "Engineering Core", ...}
# Add Bob as a MEMBER (using Bob's user ID)
curl -X POST http://localhost:8000/api/v1/teams/<TEAM_ID>/members/add/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"user_id": 2, "role": "MEMBER"}'# Create a private personal note
curl -X POST http://localhost:8000/api/v1/notes/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title": "Database Architecture", "body": "PostgreSQL full-text search and indexing strategies.", "team_id": null, "is_pinned": true}'
# Response returns: {"id": "<NOTE_ID>", "version": 1, ...}
# Update note successfully with matching expected_version (OCC)
curl -X PUT http://localhost:8000/api/v1/notes/<NOTE_ID>/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title": "Database Architecture (v2)", "body": "Added GIN index benchmarks.", "expected_version": 1, "change_summary": "Added index benchmarks"}'
# Response returns: {"id": "<NOTE_ID>", "version": 2, ...}
# Attempting an update with a stale expected_version returns 409 Conflict
curl -X PUT http://localhost:8000/api/v1/notes/<NOTE_ID>/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title": "Stale Edit", "expected_version": 1}'
# Response returns: HTTP 409 Conflict {"error": {"code": "conflict", "message": "Note was modified by another request..."}}curl -X POST http://localhost:8000/api/v1/notes/<NOTE_ID>/share-to-team/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"team_id": "<TEAM_ID>", "visibility": "TEAM"}'# Ranked full-text search matching 'PostgreSQL' across accessible notes
curl -X GET "http://localhost:8000/api/v1/notes/search/?q=PostgreSQL" \
-H "Authorization: Token <ALICE_TOKEN>"# View all audit snapshots
curl -X GET http://localhost:8000/api/v1/notes/<NOTE_ID>/history/ \
-H "Authorization: Token <ALICE_TOKEN>"
# Revert content back to version 1
curl -X POST http://localhost:8000/api/v1/notes/<NOTE_ID>/revert/ \
-H "Authorization: Token <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"version_number": 1}'# Move note to trash
curl -X DELETE http://localhost:8000/api/v1/notes/<NOTE_ID>/ \
-H "Authorization: Token <ALICE_TOKEN>"
# List notes in trash
curl -X GET http://localhost:8000/api/v1/notes/trash/ \
-H "Authorization: Token <ALICE_TOKEN>"
# Restore note from trash
curl -X POST http://localhost:8000/api/v1/notes/<NOTE_ID>/restore/ \
-H "Authorization: Token <ALICE_TOKEN>"A pre-configured Postman collection is included at postman_collection.json.
- Open Postman and click Import.
- Select the
postman_collection.jsonfile from this repository. - The collection is pre-configured with dynamic variables (
{{base_url}},{{auth_token}},{{team_id}},{{note_id}},{{note_version}}). - Automated test scripts extract tokens and resource IDs sequentially from responses and propagate them automatically across subsequent requests.
- Run the requests in folder order (
1. Authentication->2. Teams->3. Tags->4. Notes->5. Search->6. Trash).