From c1f0d992f8ad3e8ed2bd9558658b35143b32637c Mon Sep 17 00:00:00 2001 From: ChanukaUOJ Date: Thu, 21 May 2026 15:02:36 +0530 Subject: [PATCH 1/4] [BACKEND] enable multiple emails and contacts storing feature for receiver --- tool/backend/sql/README.md | 99 +++++++++++++++- tool/backend/sql/schema.sql | 11 +- tool/backend/sql/seed.sql | 46 ++++---- .../src/models/request_models/receiver.py | 12 +- .../src/models/response_models/receivers.py | 16 +-- .../src/models/table_schemas/table_schemas.py | 6 +- tool/backend/src/services/receiver_service.py | 12 +- tool/backend/test/conftest.py | 35 +++--- tool/backend/test/test_receiver_service.py | 107 +++++++++--------- 9 files changed, 217 insertions(+), 127 deletions(-) diff --git a/tool/backend/sql/README.md b/tool/backend/sql/README.md index d0579140..0cc7e81c 100644 --- a/tool/backend/sql/README.md +++ b/tool/backend/sql/README.md @@ -3,25 +3,118 @@ This directory contains the SQL scripts used to initialize and seed the RTI-Tracker database. ## Directory Structure + - **`schema.sql`**: The core database structure (tables, extensions, constraints). - **`seed.sql`**: Mock data for development and testing. +#### DB Schema + +```mermaid + erDiagram + SENDERS ||--o{ RTI_REQUESTS : initiates + RECEIVERS ||--o{ RTI_REQUESTS : targets + RTI_REQUESTS ||--|{ RTI_STATUS_HISTORIES : tracks + RECEIVERS }o--|| INSTITUTIONS : has + RECEIVERS }o--|| POSITIONS : has + RTI_STATUS_HISTORIES }o--|| RTI_STATUSES : has + RTI_REQUESTS }o--o| RTI_TEMPLATES : has + + SENDERS { + uuid id PK + string name + string email + string address + string contact_no + datetime created_at + datetime updated_at + } + + POSITIONS { + uuid id PK + string name + datetime created_at + datetime updated_at + } + + INSTITUTIONS { + uuid id PK + string name + datetime created_at + datetime updated_at + } + + RECEIVERS { + uuid id PK + uuid position_id FK + uuid institution_id FK + list[string] emails + string address + list[string] contact_nos + datetime created_at + datetime updated_at + } + + RTI_TEMPLATES { + uuid id PK + string title + string description + string file + datetime created_at + datetime updated_at + } + + RTI_REQUESTS { + uuid id PK + string title + string description + uuid sender_id FK + uuid receiver_id FK + uuid rti_template_id FK + datetime created_at + datetime updated_at + } + + RTI_STATUS_HISTORIES { + uuid id PK + uuid rti_request_id FK + uuid status_id FK + string direction + string description + datetime entry_time + datetime exit_time + list[string] files + datetime created_at + datetime updated_at + } + + RTI_STATUSES { + uuid id PK + string name + datetime created_at + datetime updated_at + } +``` + ## Local Development + To reset your local database and apply fresh schema/seed data, run: #### Environment setup **macOS / Linux** + ```bash cp .env.example .env ``` **Windows (PowerShell)** + ```powershell Copy-Item .env.example .env ``` **Windows (Command Prompt)** + ```cmd copy .env.example .env ``` @@ -33,9 +126,11 @@ copy .env.example .env docker compose down -v && docker compose up --build ``` -*Note: The `-v` flag is required to clear the named volume and trigger re-initialization.* +_Note: The `-v` flag is required to clear the named volume and trigger re-initialization._ ## Production + For production environments (like Neon): + 1. Execute **`schema.sql`** to set up the tables. -2. **Do not** run `seed.sql` unless you specifically need mock data in a staging environment. \ No newline at end of file +2. **Do not** run `seed.sql` unless you specifically need mock data in a staging environment. diff --git a/tool/backend/sql/schema.sql b/tool/backend/sql/schema.sql index ce0489ca..1c600cde 100644 --- a/tool/backend/sql/schema.sql +++ b/tool/backend/sql/schema.sql @@ -35,9 +35,9 @@ CREATE TABLE IF NOT EXISTS receivers ( id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, position_id uuid NOT NULL REFERENCES positions(id), institution_id uuid NOT NULL REFERENCES institutions(id), - email VARCHAR UNIQUE, + emails JSONB, address VARCHAR, - contact_no VARCHAR UNIQUE, + contact_nos JSONB, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); @@ -94,10 +94,11 @@ ADD CONSTRAINT check_senders_email_or_contact_no CHECK (email IS NOT NULL OR contact_no IS NOT NULL); -- RECEIVERS TABLE -ALTER TABLE receivers DROP CONSTRAINT IF EXISTS check_receivers_email_or_contact_no; +ALTER TABLE receivers DROP CONSTRAINT IF EXISTS check_receivers_emails_or_contact_nos; ALTER TABLE receivers -ADD CONSTRAINT check_receivers_email_or_contact_no -CHECK (email IS NOT NULL OR contact_no IS NOT NULL); +ADD CONSTRAINT check_receivers_emails_or_contact_nos +CHECK ((emails IS NOT NULL AND emails <> '[]'::jsonb) OR +(contact_nos IS NOT NULL AND contact_nos <> '[]'::jsonb)); -- Foreign Key Indexes -- RECEIVERS TABLE diff --git a/tool/backend/sql/seed.sql b/tool/backend/sql/seed.sql index a63697f5..baf5d29e 100644 --- a/tool/backend/sql/seed.sql +++ b/tool/backend/sql/seed.sql @@ -41,17 +41,17 @@ INSERT INTO senders (name, email, address, contact_no) VALUES -- 4. RECEIVERS -- Linking using subqueries to match existing data -INSERT INTO receivers (position_id, institution_id, email, address, contact_no) VALUES -((SELECT id FROM positions WHERE name = 'Information Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Health' LIMIT 1), 'io.health@gov.lk', NULL, '0112444555'), -((SELECT id FROM positions WHERE name = 'Designated Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Department of Education' LIMIT 1), 'do.edu@gov.lk', NULL, NULL), -((SELECT id FROM positions WHERE name = 'Secretary' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Central Environmental Authority' LIMIT 1), NULL, 'Colombo', '0112888999'), -((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), 'dg.rda@gov.lk', 'Colombo', '0112000111'), -((SELECT id FROM positions WHERE name = 'Legal Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Police' LIMIT 1), NULL, NULL, '0112222333'), -((SELECT id FROM positions WHERE name = 'Administrative Assistant' LIMIT 1), (SELECT id FROM institutions WHERE name = 'National Water Supply & Drainage Board' LIMIT 1), 'admin.nwsdb@gov.lk', 'Colombo', '0112555666'), -((SELECT id FROM positions WHERE name = 'Research Analyst' LIMIT 1), (SELECT id FROM institutions WHERE name = 'University of Colombo' LIMIT 1), 'research.uoc@ac.lk', NULL, NULL), -((SELECT id FROM positions WHERE name = 'Public Relations Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Customs' LIMIT 1), 'pro.customs@gov.lk', 'Colombo', '0112111222'), -((SELECT id FROM positions WHERE name = 'Chief Executive Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Finance' LIMIT 1), 'ceo.finance@gov.lk', 'Gampaha', '0112999000'), -((SELECT id FROM positions WHERE name = 'Department Head' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Public Service Commission' LIMIT 1), 'head.psc@gov.lk', 'Gampaha', NULL); +INSERT INTO receivers (position_id, institution_id, emails, address, contact_nos) VALUES +((SELECT id FROM positions WHERE name = 'Information Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Health' LIMIT 1), '["io.health@gov.lk"]', NULL, '["0112444555"]'), +((SELECT id FROM positions WHERE name = 'Designated Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Department of Education' LIMIT 1), '["do.edu@gov.lk"]', NULL, '[]'), +((SELECT id FROM positions WHERE name = 'Secretary' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Central Environmental Authority' LIMIT 1), '[]', 'Colombo', '["0112888999"]'), +((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), '["dg.rda@gov.lk"]', 'Colombo', '["0112000111"]'), +((SELECT id FROM positions WHERE name = 'Legal Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Police' LIMIT 1), '[]', NULL, '["0112222333"]'), +((SELECT id FROM positions WHERE name = 'Administrative Assistant' LIMIT 1), (SELECT id FROM institutions WHERE name = 'National Water Supply & Drainage Board' LIMIT 1), '["admin.nwsdb@gov.lk"]', 'Colombo', '["0112555666"]'), +((SELECT id FROM positions WHERE name = 'Research Analyst' LIMIT 1), (SELECT id FROM institutions WHERE name = 'University of Colombo' LIMIT 1), '["research.uoc@ac.lk"]', NULL, '[]'), +((SELECT id FROM positions WHERE name = 'Public Relations Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Customs' LIMIT 1), '["pro.customs@gov.lk"]', 'Colombo', '["0112111222"]'), +((SELECT id FROM positions WHERE name = 'Chief Executive Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Finance' LIMIT 1), '["ceo.finance@gov.lk"]', 'Gampaha', '["0112999000"]'), +((SELECT id FROM positions WHERE name = 'Department Head' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Public Service Commission' LIMIT 1), '["head.psc@gov.lk"]', 'Gampaha', '[]'); -- 5. RTI TEMPLATES INSERT INTO rti_templates (title, description, file) VALUES @@ -79,16 +79,16 @@ INSERT INTO rti_statuses (name) VALUES -- 7. RTI REQUESTS INSERT INTO rti_requests (title, description, sender_id, receiver_id, rti_template_id, created_at) VALUES -('Inquiry on Hospital Supplies', 'Requesting details of medicine availability for Colombo South Hospital.', (SELECT id FROM senders WHERE name = 'Amal Perera' LIMIT 1), (SELECT id FROM receivers WHERE email = 'io.health@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Health Statistics Request' LIMIT 1), NOW() - INTERVAL '30 days'), -('School Expenditure 2024', 'Requesting budget allocation for primary schools in Jaffna.', (SELECT id FROM senders WHERE name = 'Bimali Silva' LIMIT 1), (SELECT id FROM receivers WHERE email = 'do.edu@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Education Data Request' LIMIT 1), NOW() - INTERVAL '29 days'), -('Highway Project Budget', 'Details on the funding sources for the Central Expressway Phase III.', (SELECT id FROM senders WHERE name = 'Chamara Bandara' LIMIT 1), (SELECT id FROM receivers WHERE email = 'dg.rda@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Infrastructure Project Details' LIMIT 1), NOW() - INTERVAL '28 days'), -('Environmental Clearance List', 'Requesting a list of projects approved in wetlands during 2023.', (SELECT id FROM senders WHERE name = 'Dilini Fernando' LIMIT 1), (SELECT id FROM receivers WHERE contact_no = '0112888999' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Environmental Impact Inquiry' LIMIT 1), NOW() - INTERVAL '27 days'), -('Police Recruitment Ratio', 'Requesting data on gender ratio in recent constable recruitment.', (SELECT id FROM senders WHERE name = 'Eshani Jayawardena' LIMIT 1), (SELECT id FROM receivers WHERE contact_no = '0112222333' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Public Service Vacancy Info' LIMIT 1), NOW() - INTERVAL '26 days'), -('NWSDB Water Quality Data', 'Requesting seasonal water quality reports for Kandy district.', (SELECT id FROM senders WHERE name = 'Fathima Nasreen' LIMIT 1), (SELECT id FROM receivers WHERE email = 'admin.nwsdb@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Water Supply Project Status' LIMIT 1), NOW() - INTERVAL '25 days'), -('University Research Grants', 'Summary of research grants awarded to UoC faculty in 2025.', (SELECT id FROM senders WHERE name = 'Gayan Ratnayake' LIMIT 1), (SELECT id FROM receivers WHERE email = 'research.uoc@ac.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'General Request Template' LIMIT 1), NOW() - INTERVAL '24 days'), -('Customs Duty Exemptions', 'List of organizations granted duty exemptions for vehicle imports.', (SELECT id FROM senders WHERE name = 'Harsha Kumara' LIMIT 1), (SELECT id FROM receivers WHERE email = 'pro.customs@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Customs Import/Export Data' LIMIT 1), NOW() - INTERVAL '23 days'), -('Foreign Debt Repayment', 'Quarterly report on interest paid toward sovereign bonds.', (SELECT id FROM senders WHERE name = 'Iromi Wickramasinghe' LIMIT 1), (SELECT id FROM receivers WHERE email = 'ceo.finance@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Financial Expenditure Inquiry' LIMIT 1), NOW() - INTERVAL '22 days'), -('PSC Disciplinary Actions', 'Stats on administrative inquiries concluded in the last 6 months.', (SELECT id FROM senders WHERE name = 'Janaka Abeysekera' LIMIT 1), (SELECT id FROM receivers WHERE email = 'head.psc@gov.lk' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Legal Proceeding Inquiry' LIMIT 1), NOW() - INTERVAL '21 days'); +('Inquiry on Hospital Supplies', 'Requesting details of medicine availability for Colombo South Hospital.', (SELECT id FROM senders WHERE name = 'Amal Perera' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["io.health@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Health Statistics Request' LIMIT 1), NOW() - INTERVAL '30 days'), +('School Expenditure 2024', 'Requesting budget allocation for primary schools in Jaffna.', (SELECT id FROM senders WHERE name = 'Bimali Silva' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["do.edu@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Education Data Request' LIMIT 1), NOW() - INTERVAL '29 days'), +('Highway Project Budget', 'Details on the funding sources for the Central Expressway Phase III.', (SELECT id FROM senders WHERE name = 'Chamara Bandara' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["dg.rda@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Infrastructure Project Details' LIMIT 1), NOW() - INTERVAL '28 days'), +('Environmental Clearance List', 'Requesting a list of projects approved in wetlands during 2023.', (SELECT id FROM senders WHERE name = 'Dilini Fernando' LIMIT 1), (SELECT id FROM receivers WHERE contact_nos @> '["0112888999"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Environmental Impact Inquiry' LIMIT 1), NOW() - INTERVAL '27 days'), +('Police Recruitment Ratio', 'Requesting data on gender ratio in recent constable recruitment.', (SELECT id FROM senders WHERE name = 'Eshani Jayawardena' LIMIT 1), (SELECT id FROM receivers WHERE contact_nos @> '["0112222333"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Public Service Vacancy Info' LIMIT 1), NOW() - INTERVAL '26 days'), +('NWSDB Water Quality Data', 'Requesting seasonal water quality reports for Kandy district.', (SELECT id FROM senders WHERE name = 'Fathima Nasreen' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["admin.nwsdb@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Water Supply Project Status' LIMIT 1), NOW() - INTERVAL '25 days'), +('University Research Grants', 'Summary of research grants awarded to UoC faculty in 2025.', (SELECT id FROM senders WHERE name = 'Gayan Ratnayake' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["research.uoc@ac.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'General Request Template' LIMIT 1), NOW() - INTERVAL '24 days'), +('Customs Duty Exemptions', 'List of organizations granted duty exemptions for vehicle imports.', (SELECT id FROM senders WHERE name = 'Harsha Kumara' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["pro.customs@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Customs Import/Export Data' LIMIT 1), NOW() - INTERVAL '23 days'), +('Foreign Debt Repayment', 'Quarterly report on interest paid toward sovereign bonds.', (SELECT id FROM senders WHERE name = 'Iromi Wickramasinghe' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["ceo.finance@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Financial Expenditure Inquiry' LIMIT 1), NOW() - INTERVAL '22 days'), +('PSC Disciplinary Actions', 'Stats on administrative inquiries concluded in the last 6 months.', (SELECT id FROM senders WHERE name = 'Janaka Abeysekera' LIMIT 1), (SELECT id FROM receivers WHERE emails @> '["head.psc@gov.lk"]' LIMIT 1), (SELECT id FROM rti_templates WHERE title = 'Legal Proceeding Inquiry' LIMIT 1), NOW() - INTERVAL '21 days'); -- 8. RTI STATUS HISTORIES -- First, add the mandatory 'CREATED' status for every request @@ -145,8 +145,8 @@ WHERE title = 'Education Data Request'; -- Scenario 4: Update a receiver UPDATE receivers -SET email = 'do.edu.updated@gov.lk', updated_at = NOW() -WHERE email = 'do.edu@gov.lk'; +SET emails = '["do.edu.updated@gov.lk","do.edu2@gov.lk","do.edu@gov.lk"]'::jsonb, updated_at = NOW() +WHERE emails @> '["do.edu@gov.lk"]'; -- Scenario 5: Update an institution UPDATE institutions diff --git a/tool/backend/src/models/request_models/receiver.py b/tool/backend/src/models/request_models/receiver.py index 1734a183..b0eaac12 100644 --- a/tool/backend/src/models/request_models/receiver.py +++ b/tool/backend/src/models/request_models/receiver.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field, ConfigDict, EmailStr, model_validator -from typing import Optional +from typing import Optional, List from uuid import UUID from src.core.exceptions import BadRequestException @@ -8,21 +8,21 @@ class ReceiverUpdateRequest(BaseModel): position_id: Optional[UUID] = Field(None, alias="positionId", description="ID of the position") institution_id: Optional[UUID] = Field(None, alias="institutionId", description="ID of the institution") - email: Optional[EmailStr] = Field(None, description="Email of the receiver") + emails: Optional[List[EmailStr]] = Field(None, description="List of receiver Emails") address: Optional[str] = Field(None, description="Address of the receiver") - contact_no: Optional[str] = Field(None, alias="contactNo", pattern=r"^(?:\+94|0)\d{9}$", description="Contact number of the receiver") + contact_nos: Optional[List[str]] = Field(None, alias="contactNos", description="List of receiver contact numbers") class ReceiverRequest(BaseModel): model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True, populate_by_name=True) position_id: UUID = Field(..., alias="positionId", description="ID of the position") institution_id: UUID = Field(..., alias="institutionId", description="ID of the institution") - email: Optional[EmailStr] = Field(None, description="Email of the receiver") + emails: Optional[List[EmailStr]] = Field(None, description="List of receiver Emails") address: Optional[str] = Field(None, description="Address of the receiver") - contact_no: Optional[str] = Field(None, alias="contactNo", pattern=r"^(?:\+94|0)\d{9}$", description="Contact number of the receiver") + contact_nos: Optional[List[str]] = Field(None, alias="contactNos", description="List of receiver contact numbers") @model_validator(mode="after") def validate_email_or_contact(self): - if not self.email and not self.contact_no: + if not self.emails and not self.contact_nos: raise BadRequestException("Either email or contactNo must be provided.") return self diff --git a/tool/backend/src/models/response_models/receivers.py b/tool/backend/src/models/response_models/receivers.py index 00a9af0e..8159a222 100644 --- a/tool/backend/src/models/response_models/receivers.py +++ b/tool/backend/src/models/response_models/receivers.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Sequence +from typing import Optional, Sequence, List from src.models import Position, Institution, PaginationModel from src.models.response_models import PositionResponse, InstitutionResponse, PositionShortResponse, InstitutionShortResponse from uuid import UUID @@ -11,16 +11,16 @@ class ReceiverResponse(BaseModel): id: UUID = Field(..., description="Unique identifier for the receiver") position: PositionResponse = Field(..., description="Position object of the receiver") institution: InstitutionResponse = Field(..., description="Institution object of the receiver") - email: Optional[str] = Field( - None, description="Email of the receiver" + emails: Optional[List[str]] = Field( + None, description="Email list of the receiver" ) address: Optional[str] = Field( None, description="Address of the receiver" ) - contact_no: Optional[str] = Field( + contact_nos: Optional[List[str]] = Field( None, - serialization_alias="contactNo", - description="Contact number of the receiver" + serialization_alias="contactNos", + description="Contact number list of the receiver" ) created_at: datetime = Field(..., serialization_alias="createdAt", description="ISO 8601 timestamp of when the receiver was created") updated_at: datetime = Field(..., serialization_alias="updatedAt", description="ISO 8601 timestamp of when the receiver was last updated") @@ -31,9 +31,9 @@ class ReceiverShortResponse(BaseModel): id: UUID = Field(..., description="Unique identifier for the receiver") position: PositionShortResponse = Field(..., description="Position object of the receiver") institution: InstitutionShortResponse = Field(..., description="Institution object of the receiver") - email: Optional[str] = Field(None, description="Email of the receiver") + emails: Optional[List[str]] = Field(None, description="Email list of the receiver") address: Optional[str] = Field(None, description="Address of the receiver") - contact_no: Optional[str] = Field(None, serialization_alias="contactNo", description="Contact number of the receiver") + contact_nos: Optional[List[str]] = Field(None, serialization_alias="contactNos", description="Contact number list of the receiver") class ReceiverListResponse(BaseModel): model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True) diff --git a/tool/backend/src/models/table_schemas/table_schemas.py b/tool/backend/src/models/table_schemas/table_schemas.py index 7bbeba18..0e948571 100644 --- a/tool/backend/src/models/table_schemas/table_schemas.py +++ b/tool/backend/src/models/table_schemas/table_schemas.py @@ -81,7 +81,7 @@ class Receiver(SQLModel, table=True): __table_args__ = ( CheckConstraint( - "email IS NOT NULL OR contact_no IS NOT NULL", name="check_receivers_email_or_contact_no" + "emails IS NOT NULL OR contact_nos IS NOT NULL", name="check_receivers_emails_or_contact_nos" ), ) @@ -89,9 +89,9 @@ class Receiver(SQLModel, table=True): id: UUID = Field(primary_key=True, description="Unique identifier for the receiver") position_id: UUID = Field(foreign_key="positions.id", description="ID of the position") institution_id: UUID = Field(foreign_key="institutions.id", description="ID of the institution") - email: Optional[str] = Field(None, unique=True, description="Email of the receiver") + emails: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSONB().with_variant(JSON, "sqlite")), description="List of email of the receiver") address: Optional[str] = Field(None, description="Address of the receiver") - contact_no: Optional[str] = Field(None, unique=True, description="Contact number of the receiver") + contact_nos: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSONB().with_variant(JSON, "sqlite")), description="List of contact number of the receiver") created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="ISO 8601 timestamp of when the receiver was created", diff --git a/tool/backend/src/services/receiver_service.py b/tool/backend/src/services/receiver_service.py index eabe3b09..549f6919 100644 --- a/tool/backend/src/services/receiver_service.py +++ b/tool/backend/src/services/receiver_service.py @@ -25,9 +25,9 @@ def create_receiver(self, *, receiver_request: ReceiverRequest) -> ReceiverRespo id=uuid4(), position_id=receiver_request.position_id, institution_id=receiver_request.institution_id, - email=receiver_request.email, + emails=receiver_request.emails, address=receiver_request.address, - contact_no=receiver_request.contact_no + contact_nos=receiver_request.contact_nos ) self.session.add(receiver) self.session.commit() @@ -37,9 +37,9 @@ def create_receiver(self, *, receiver_request: ReceiverRequest) -> ReceiverRespo except IntegrityError as e: self.session.rollback() error_msg = str(e.orig) - if "receivers_email_key" in error_msg or "receivers.email" in error_msg: + if "receivers_emails_key" in error_msg or "receivers.emails" in error_msg: raise ConflictException("Email already exists for another receiver.") - if "receivers_contact_no_key" in error_msg or "receivers.contact_no" in error_msg: + if "receivers_contact_nos_key" in error_msg or "receivers.contact_nos" in error_msg: raise ConflictException("Contact number already exists for another receiver.") # Clean up the DB error message to return it directly @@ -148,9 +148,9 @@ def update_receiver(self, *, receiver_id: UUID, receiver_request: ReceiverUpdate except IntegrityError as e: self.session.rollback() error_msg = str(e.orig) - if "receivers_email_key" in error_msg or "receivers.email" in error_msg: + if "receivers_emails_key" in error_msg or "receivers.emails" in error_msg: raise ConflictException("Email already exists for another receiver.") - if "receivers_contact_no_key" in error_msg or "receivers.contact_no" in error_msg: + if "receivers_contact_nos_key" in error_msg or "receivers.contact_nos" in error_msg: raise ConflictException("Contact number already exists for another receiver.") # Clean up the DB error message to return it directly diff --git a/tool/backend/test/conftest.py b/tool/backend/test/conftest.py index 04333ce6..5cfcde1a 100644 --- a/tool/backend/test/conftest.py +++ b/tool/backend/test/conftest.py @@ -5,8 +5,7 @@ from datetime import datetime, timezone, timedelta from sqlmodel import SQLModel, Session, create_engine from src.models import RTITemplate, Institution, Position, Receiver, ReceiverRequest, ReceiverUpdateRequest, RTIRequest, RTIStatus, RTIStatusHistory, RTIStatusName -from src.models.request_models import RTITemplateRequest, PositionRequest -from src.services.github_file_service import GithubFileService +from src.models.request_models import RTITemplateRequest, PositionRequest, RTIStatusRequest from fastapi import UploadFile from sqlalchemy import event from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock @@ -311,7 +310,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): id=uuid.uuid4(), position=pos1, institution=inst1, - email="receiver1@example.com", + emails=["receiver1@example.com"], created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=2), ), @@ -319,7 +318,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): id=uuid.uuid4(), position=pos1, institution=inst1, - email="receiver2@example.com", + emails=["receiver2@example.com"], created_at=now - timedelta(hours=1), updated_at=now - timedelta(hours=1), ), @@ -327,13 +326,13 @@ def set_sqlite_pragma(dbapi_connection, connection_record): id=uuid.uuid4(), position=pos1, institution=inst1, - email="receiver3@example.com", + emails=["receiver3@example.com"], created_at=now, updated_at=now, ), - Receiver(id=uuid.uuid4(), position=pos_legal, institution=inst_health, email="r1@example.com", created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=2)), - Receiver(id=uuid.uuid4(), position=pos_director, institution=inst_finance, email="r2@example.com", created_at=now - timedelta(hours=1), updated_at=now - timedelta(hours=1)), - Receiver(id=uuid.uuid4(), position=pos_admin, institution=inst_police, email="r3@example.com", created_at=now, updated_at=now), + Receiver(id=uuid.uuid4(), position=pos_legal, institution=inst_health, emails=["r1@example.com"], created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=2)), + Receiver(id=uuid.uuid4(), position=pos_director, institution=inst_finance, emails=["r2@example.com"], created_at=now - timedelta(hours=1), updated_at=now - timedelta(hours=1)), + Receiver(id=uuid.uuid4(), position=pos_admin, institution=inst_police, emails=["r3@example.com"], created_at=now, updated_at=now), ] with Session(engine) as session: @@ -351,16 +350,16 @@ def make_receiver_request(): def _factory( position_id: uuid.UUID, institution_id: uuid.UUID, - email: str | None = "new@example.com", + emails: list[str] | None = None, address: str | None = "New Address", - contact_no: str | None = "0771234568", + contact_nos: list[str] | None = None, ) -> ReceiverRequest: return ReceiverRequest( positionId=position_id, institutionId=institution_id, - email=email, + emails=emails if emails is not None else ["new@example.com"], address=address, - contactNo=contact_no + contactNos=contact_nos if contact_nos is not None else ["0771234568"], ) return _factory @@ -372,18 +371,17 @@ def make_receiver_update_request(): def _factory( position_id: uuid.UUID | None = None, institution_id: uuid.UUID | None = None, - email: str | None = None, + emails: list[str] | None = None, address: str | None = None, - contact_no: str | None = None, + contact_nos: list[str] | None = None, ) -> ReceiverUpdateRequest: # Use dict and then parse to handle exclude_unset=True in model_dump data = {} if position_id is not None: data["positionId"] = position_id if institution_id is not None: data["institutionId"] = institution_id - if email is not None: data["email"] = email + if emails is not None: data["emails"] = emails if address is not None: data["address"] = address - if contact_no is not None: data["contactNo"] = contact_no - + if contact_nos is not None: data["contactNos"] = contact_nos return ReceiverUpdateRequest(**data) return _factory @@ -490,7 +488,6 @@ def mock_sender_service(): @pytest.fixture def make_rti_status_request(): """Factory for StatusRequest instances.""" - from src.models.request_models import RTIStatusRequest def _factory(name: str = "Dispatched") -> RTIStatusRequest: return RTIStatusRequest(name=name) @@ -563,7 +560,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): id=uuid.uuid4(), position_id=pos.id, institution_id=inst.id, - email="receiver@example.com", + emails=["receiver@example.com"], created_at=now, updated_at=now ) diff --git a/tool/backend/test/test_receiver_service.py b/tool/backend/test/test_receiver_service.py index 78ad2a36..71c5a868 100644 --- a/tool/backend/test/test_receiver_service.py +++ b/tool/backend/test/test_receiver_service.py @@ -21,9 +21,9 @@ def test_get_receivers_default(receiver_db): assert len(response.data) == 6 # verify sorting order (descending by created_at) # Receiver 3 (now) should be first, Receiver 1 (now - 2h) should be last - assert response.data[0].email == "receiver3@example.com" - assert response.data[1].email == "r3@example.com" - assert response.data[2].email == "receiver2@example.com" + assert response.data[0].emails == ["receiver3@example.com"] + assert response.data[1].emails == ["r3@example.com"] + assert response.data[2].emails == ["receiver2@example.com"] # Verify eager loading relationships assert response.data[0].position is not None @@ -39,7 +39,7 @@ def test_get_receivers_custom_pagination(receiver_db): assert response.pagination.total_items == 6 assert response.pagination.total_pages == 3 assert len(response.data) == 2 # Only 2 record left for page 2 - assert response.data[0].email == "receiver2@example.com" + assert response.data[0].emails == ["receiver2@example.com"] def test_get_receivers_empty_db(): """Test behavior when no receivers exist in the database.""" @@ -80,31 +80,28 @@ def test_create_receiver_success(receiver_db, make_receiver_request): response = service.create_receiver(receiver_request=request) assert isinstance(response, ReceiverResponse) - assert response.email == request.email + assert response.emails == request.emails assert response.address == request.address - assert response.contact_no == request.contact_no + assert response.contact_nos == request.contact_nos def test_create_receiver_conflict(receiver_db, make_receiver_request): - """Test receiver creation with duplicate email.""" + """Test receiver creation with duplicate emails list.""" pos = receiver_db.exec(select(Position)).first() inst = receiver_db.exec(select(Institution)).first() - - # Create first receiver - request1 = make_receiver_request(position_id=pos.id, institution_id=inst.id, email="dup@example.com") + + request1 = make_receiver_request(position_id=pos.id, institution_id=inst.id, emails=["dup@example.com"]) service = ReceiverService(session=receiver_db) - service.create_receiver(receiver_request=request1) - - # Try to create second receiver with same email but different contact number + response1 = service.create_receiver(receiver_request=request1) + assert response1.emails == ["dup@example.com"] + request2 = make_receiver_request( - position_id=pos.id, - institution_id=inst.id, - email="dup@example.com", - contact_no="0779999999" + position_id=pos.id, + institution_id=inst.id, + emails=["dup2@example.com"], + contact_nos=["0779999999"] ) - - with pytest.raises(ConflictException) as excinfo: - service.create_receiver(receiver_request=request2) - assert "Email already exists" in str(excinfo.value) + response2 = service.create_receiver(receiver_request=request2) + assert response2.emails == ["dup2@example.com"] def test_get_receiver_by_id_success(receiver_db): """Test fetching a receiver by ID.""" @@ -114,7 +111,7 @@ def test_get_receiver_by_id_success(receiver_db): response = service.get_receiver_by_id(receiver_id=existing.id) assert response.id == existing.id - assert response.email == existing.email + assert response.emails == existing.emails def test_get_receiver_by_id_not_found(receiver_db): """Test fetching a non-existent receiver.""" @@ -129,15 +126,15 @@ def test_update_receiver_success(receiver_db, make_receiver_request): service = ReceiverService(session=receiver_db) request = make_receiver_request( - position_id=existing.position_id, + position_id=existing.position_id, institution_id=existing.institution_id, - email="updated@example.com" + emails=["updated@example.com"] ) - + response = service.update_receiver(receiver_id=existing.id, receiver_request=request) - + assert response.id == existing.id - assert response.email == "updated@example.com" + assert response.emails == ["updated@example.com"] def test_update_receiver_not_found(receiver_db, make_receiver_request): """Test updating a non-existent receiver.""" @@ -167,45 +164,45 @@ def test_delete_receiver_not_found(receiver_db): assert "not found" in str(excinfo.value) def test_update_receiver_partial_email(receiver_db, make_receiver_update_request): - """Test partial update of only the email field.""" + """Test partial update of only the emails field.""" existing = receiver_db.exec(select(Receiver)).first() original_address = existing.address - original_contact = existing.contact_no - + original_contact_nos = existing.contact_nos + service = ReceiverService(session=receiver_db) - request = make_receiver_update_request(email="partial@example.com") - + request = make_receiver_update_request(emails=["partial@example.com"]) + response = service.update_receiver(receiver_id=existing.id, receiver_request=request) - - assert response.email == "partial@example.com" + + assert response.emails == ["partial@example.com"] assert response.address == original_address - assert response.contact_no == original_contact + assert response.contact_nos == original_contact_nos def test_update_receiver_partial_address(receiver_db, make_receiver_update_request): """Test partial update of only the address field.""" existing = receiver_db.exec(select(Receiver)).first() - original_email = existing.email - + original_emails = existing.emails + service = ReceiverService(session=receiver_db) request = make_receiver_update_request(address="Updated partial address") - + response = service.update_receiver(receiver_id=existing.id, receiver_request=request) - + assert response.address == "Updated partial address" - assert response.email == original_email + assert response.emails == original_emails -def test_update_receiver_partial_contact_no(receiver_db, make_receiver_update_request): - """Test partial update of only the contact_no field.""" +def test_update_receiver_partial_contact_nos(receiver_db, make_receiver_update_request): + """Test partial update of only the contact_nos field.""" existing = receiver_db.exec(select(Receiver)).first() - original_email = existing.email - + original_emails = existing.emails + service = ReceiverService(session=receiver_db) - request = make_receiver_update_request(contact_no="0779876543") - + request = make_receiver_update_request(contact_nos=["0779876543"]) + response = service.update_receiver(receiver_id=existing.id, receiver_request=request) - - assert response.contact_no == "0779876543" - assert response.email == original_email + + assert response.contact_nos == ["0779876543"] + assert response.emails == original_emails def test_update_receiver_partial_position(receiver_db, make_receiver_update_request): """Test partial update of only the position field.""" @@ -285,7 +282,7 @@ def test_get_receivers_filter_by_institution_name(receiver_db): assert isinstance(response, ReceiverListResponse) assert response.pagination.total_items == 1 - assert response.data[0].email == "r1@example.com" + assert response.data[0].emails == ["r1@example.com"] assert response.data[0].institution.name == "Ministry of Health" @@ -296,7 +293,7 @@ def test_get_receivers_filter_by_institution_name_partial(receiver_db): response = service.get_receivers(query="Ministry") assert response.pagination.total_items == 2 - emails = {r.email for r in response.data} + emails = {r.emails[0] for r in response.data} assert emails == {"r1@example.com", "r2@example.com"} @@ -306,7 +303,7 @@ def test_get_receivers_filter_by_position_name(receiver_db): response = service.get_receivers(query="Director General") assert response.pagination.total_items == 1 - assert response.data[0].email == "r2@example.com" + assert response.data[0].emails == ["r2@example.com"] assert response.data[0].position.name == "Director General" @@ -316,7 +313,7 @@ def test_get_receivers_filter_by_position_name_partial(receiver_db): response = service.get_receivers(query="Admin") assert response.pagination.total_items == 1 - assert response.data[0].email == "r3@example.com" + assert response.data[0].emails == ["r3@example.com"] def test_get_receivers_filter_case_insensitive_institution(receiver_db): @@ -341,7 +338,7 @@ def test_get_receivers_filter_case_insensitive_position(receiver_db): assert response_lower.pagination.total_items == 1 assert response_upper.pagination.total_items == 1 - assert response_lower.data[0].email == "r1@example.com" + assert response_lower.data[0].emails == ["r1@example.com"] def test_get_receivers_filter_no_match(receiver_db): @@ -389,7 +386,7 @@ def test_get_receivers_filter_with_pagination(receiver_db): assert len(response_p2.data) == 1 # Combined, they cover both matching receivers - emails = {response_p1.data[0].email, response_p2.data[0].email} + emails = {response_p1.data[0].emails[0], response_p2.data[0].emails[0]} assert emails == {"r1@example.com", "r2@example.com"} From 5e1e4f5c5c63ff373a93f723a2f65046584615f0 Mon Sep 17 00:00:00 2001 From: ChanukaUOJ Date: Thu, 21 May 2026 15:38:08 +0530 Subject: [PATCH 2/4] bot comments resolved --- .../src/models/table_schemas/table_schemas.py | 6 ++--- tool/backend/src/services/receiver_service.py | 4 --- tool/backend/test/test_receiver_service.py | 26 ++++++++++++++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/tool/backend/src/models/table_schemas/table_schemas.py b/tool/backend/src/models/table_schemas/table_schemas.py index 0e948571..2c4d2049 100644 --- a/tool/backend/src/models/table_schemas/table_schemas.py +++ b/tool/backend/src/models/table_schemas/table_schemas.py @@ -89,9 +89,9 @@ class Receiver(SQLModel, table=True): id: UUID = Field(primary_key=True, description="Unique identifier for the receiver") position_id: UUID = Field(foreign_key="positions.id", description="ID of the position") institution_id: UUID = Field(foreign_key="institutions.id", description="ID of the institution") - emails: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSONB().with_variant(JSON, "sqlite")), description="List of email of the receiver") + emails: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSON), description="List of email of the receiver") address: Optional[str] = Field(None, description="Address of the receiver") - contact_nos: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSONB().with_variant(JSON, "sqlite")), description="List of contact number of the receiver") + contact_nos: Optional[List[str]] = Field(default_factory=list, sa_column=Column(JSON), description="List of contact number of the receiver") created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="ISO 8601 timestamp of when the receiver was created", @@ -192,7 +192,7 @@ class RTIStatusHistory(SQLModel, table=True): description: Optional[str] = Field(default=None, description="description for the RTI Status History") entry_time: datetime = Field(description="entry time for the RTI Status History") exit_time: Optional[datetime] = Field(default=None, description="exit time for the RTI Status History") - files: List[str] = Field(..., sa_column=Column(JSONB().with_variant(JSON, "sqlite")), description="List of URLs for the RTI status history files") + files: List[str] = Field(..., sa_column=Column(JSON), description="List of URLs for the RTI status history files") created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="ISO 8601 timestamp of when the rti status history was created", diff --git a/tool/backend/src/services/receiver_service.py b/tool/backend/src/services/receiver_service.py index 549f6919..83a35c82 100644 --- a/tool/backend/src/services/receiver_service.py +++ b/tool/backend/src/services/receiver_service.py @@ -37,10 +37,6 @@ def create_receiver(self, *, receiver_request: ReceiverRequest) -> ReceiverRespo except IntegrityError as e: self.session.rollback() error_msg = str(e.orig) - if "receivers_emails_key" in error_msg or "receivers.emails" in error_msg: - raise ConflictException("Email already exists for another receiver.") - if "receivers_contact_nos_key" in error_msg or "receivers.contact_nos" in error_msg: - raise ConflictException("Contact number already exists for another receiver.") # Clean up the DB error message to return it directly clean_error = error_msg.replace('\n', ' ').strip() diff --git a/tool/backend/test/test_receiver_service.py b/tool/backend/test/test_receiver_service.py index 71c5a868..2142551f 100644 --- a/tool/backend/test/test_receiver_service.py +++ b/tool/backend/test/test_receiver_service.py @@ -84,8 +84,8 @@ def test_create_receiver_success(receiver_db, make_receiver_request): assert response.address == request.address assert response.contact_nos == request.contact_nos -def test_create_receiver_conflict(receiver_db, make_receiver_request): - """Test receiver creation with duplicate emails list.""" +def test_create_receiver_accept_same_email_list_for_two_different_receivers(receiver_db, make_receiver_request): + """Test receiver creation with same email list for two different receivers.""" pos = receiver_db.exec(select(Position)).first() inst = receiver_db.exec(select(Institution)).first() @@ -97,11 +97,29 @@ def test_create_receiver_conflict(receiver_db, make_receiver_request): request2 = make_receiver_request( position_id=pos.id, institution_id=inst.id, - emails=["dup2@example.com"], + emails=["dup@example.com"], contact_nos=["0779999999"] ) response2 = service.create_receiver(receiver_request=request2) - assert response2.emails == ["dup2@example.com"] + assert response2.emails == ["dup@example.com"] + +def test_create_receiver_accept_same_contact_nos_for_two_different_receivers(receiver_db, make_receiver_request): + """Test receiver creation with same contact_nos for two different receivers.""" + pos = receiver_db.exec(select(Position)).first() + inst = receiver_db.exec(select(Institution)).first() + + request1 = make_receiver_request(position_id=pos.id, institution_id=inst.id, contact_nos=["0779999999"]) + service = ReceiverService(session=receiver_db) + response1 = service.create_receiver(receiver_request=request1) + assert response1.contact_nos == ["0779999999"] + + request2 = make_receiver_request( + position_id=pos.id, + institution_id=inst.id, + contact_nos=["0779999999"] + ) + response2 = service.create_receiver(receiver_request=request2) + assert response2.contact_nos == ["0779999999"] def test_get_receiver_by_id_success(receiver_db): """Test fetching a receiver by ID.""" From ef9af4a77005af790fdaee9615a1bbe3a1000ebe Mon Sep 17 00:00:00 2001 From: ChanukaUOJ Date: Sun, 24 May 2026 20:22:05 +0530 Subject: [PATCH 3/4] multiple emails and numbers showing in the frontend --- tool/backend/sql/seed.sql | 4 +- tool/frontend/src/components/TagInput.tsx | 106 ++++++++++++++++ tool/frontend/src/consts/regx.ts | 5 + tool/frontend/src/data/mockData.ts | 8 +- tool/frontend/src/pages/RTIDetail.tsx | 46 ++++--- tool/frontend/src/pages/Receivers.tsx | 148 +++++++++++++++------- tool/frontend/src/types/db.ts | 8 +- tool/frontend/src/types/forms.ts | 8 ++ tool/frontend/src/utils/variableUtils.ts | 4 +- 9 files changed, 262 insertions(+), 75 deletions(-) create mode 100644 tool/frontend/src/components/TagInput.tsx create mode 100644 tool/frontend/src/consts/regx.ts create mode 100644 tool/frontend/src/types/forms.ts diff --git a/tool/backend/sql/seed.sql b/tool/backend/sql/seed.sql index baf5d29e..302bd836 100644 --- a/tool/backend/sql/seed.sql +++ b/tool/backend/sql/seed.sql @@ -45,11 +45,11 @@ INSERT INTO receivers (position_id, institution_id, emails, address, contact_nos ((SELECT id FROM positions WHERE name = 'Information Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Health' LIMIT 1), '["io.health@gov.lk"]', NULL, '["0112444555"]'), ((SELECT id FROM positions WHERE name = 'Designated Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Department of Education' LIMIT 1), '["do.edu@gov.lk"]', NULL, '[]'), ((SELECT id FROM positions WHERE name = 'Secretary' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Central Environmental Authority' LIMIT 1), '[]', 'Colombo', '["0112888999"]'), -((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), '["dg.rda@gov.lk"]', 'Colombo', '["0112000111"]'), +((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), '["dg.rda@gov.lk"]', 'Colombo', '["0112000111","0771212123","0789090987","0771212123","0789090987"]'), ((SELECT id FROM positions WHERE name = 'Legal Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Police' LIMIT 1), '[]', NULL, '["0112222333"]'), ((SELECT id FROM positions WHERE name = 'Administrative Assistant' LIMIT 1), (SELECT id FROM institutions WHERE name = 'National Water Supply & Drainage Board' LIMIT 1), '["admin.nwsdb@gov.lk"]', 'Colombo', '["0112555666"]'), ((SELECT id FROM positions WHERE name = 'Research Analyst' LIMIT 1), (SELECT id FROM institutions WHERE name = 'University of Colombo' LIMIT 1), '["research.uoc@ac.lk"]', NULL, '[]'), -((SELECT id FROM positions WHERE name = 'Public Relations Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Customs' LIMIT 1), '["pro.customs@gov.lk"]', 'Colombo', '["0112111222"]'), +((SELECT id FROM positions WHERE name = 'Public Relations Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Customs' LIMIT 1), '["pro.customs@gov.lk"]', 'Colombo', '["0112111222","0113434543"]'), ((SELECT id FROM positions WHERE name = 'Chief Executive Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Finance' LIMIT 1), '["ceo.finance@gov.lk"]', 'Gampaha', '["0112999000"]'), ((SELECT id FROM positions WHERE name = 'Department Head' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Public Service Commission' LIMIT 1), '["head.psc@gov.lk"]', 'Gampaha', '[]'); diff --git a/tool/frontend/src/components/TagInput.tsx b/tool/frontend/src/components/TagInput.tsx new file mode 100644 index 00000000..3460c7ec --- /dev/null +++ b/tool/frontend/src/components/TagInput.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; + +export default function TagInput({ + tags, + onChange, + validator, + validationMessage, + placeholder, + hasError, + type = 'text', +}: { + tags: string[]; + onChange: (tags: string[]) => void; + validator?: (v: string) => boolean; + validationMessage?: string; + placeholder?: string; + hasError?: boolean; + type?: 'text' | 'email' | 'tel'; +}) { + const [raw, setRaw] = useState(''); + const [inlineError, setInlineError] = useState(''); + + const commit = (input: string) => { + const parts = input.split(',').map(s => s.trim()).filter(Boolean); + const toAdd: string[] = []; + const invalid: string[] = []; + + for (const part of parts) { + if (tags.includes(part)) continue; + if (validator && !validator(part)) { + invalid.push(part); + } else { + toAdd.push(part); + } + } + + if (toAdd.length) onChange([...tags, ...toAdd]); + + if (invalid.length) { + setInlineError(validationMessage || 'Invalid value'); + setRaw(invalid.join(', ')); + } else { + setInlineError(''); + setRaw(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ',' || e.key === 'Enter') { + e.preventDefault(); + if (raw.trim()) commit(raw); + } else if (e.key === 'Backspace' && !raw && tags.length > 0) { + onChange(tags.slice(0, -1)); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + commit(raw + e.clipboardData.getData('text')); + }; + + const handleBlur = () => { + if (raw.trim()) commit(raw); + }; + + const remove = (i: number) => onChange(tags.filter((_, idx) => idx !== i)); + + return ( +
+ { setRaw(e.target.value); setInlineError(''); }} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onBlur={handleBlur} + /> + {tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + + {tag} + + + ))} +
+ )} + {inlineError &&

{inlineError}

} +
+ ); +} \ No newline at end of file diff --git a/tool/frontend/src/consts/regx.ts b/tool/frontend/src/consts/regx.ts new file mode 100644 index 00000000..82e81a25 --- /dev/null +++ b/tool/frontend/src/consts/regx.ts @@ -0,0 +1,5 @@ + +export const Regx = { + EMAIL: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + PHONE: /^(?:\+94|0)\d{9}$/ +}; \ No newline at end of file diff --git a/tool/frontend/src/data/mockData.ts b/tool/frontend/src/data/mockData.ts index cdec72be..4a62eb5d 100644 --- a/tool/frontend/src/data/mockData.ts +++ b/tool/frontend/src/data/mockData.ts @@ -60,8 +60,8 @@ export const mockReceivers: Receiver[] = [ id: 'c1111111-1111-1111-1111-111111111111', institution: mockInstitutions[0], position: mockPositions[0], - email: 'pio.finance@gov.in', - contactNo: '011-23095228', + emails: ['pio.finance@gov.in'], + contactNos: ['011-23095228'], address: 'North Block, New Delhi', createdAt: new Date(), updatedAt: new Date(), @@ -70,8 +70,8 @@ export const mockReceivers: Receiver[] = [ id: 'c2222222-2222-2222-2222-222222222222', institution: mockInstitutions[1], position: mockPositions[1], - email: 'aa.defense@gov.in', - contactNo: '011-23012284', + emails: ['aa.defense@gov.in'], + contactNos: ['011-23012284'], address: 'South Block, New Delhi', createdAt: new Date(), updatedAt: new Date(), diff --git a/tool/frontend/src/pages/RTIDetail.tsx b/tool/frontend/src/pages/RTIDetail.tsx index 28f2e9b5..d563b3c2 100644 --- a/tool/frontend/src/pages/RTIDetail.tsx +++ b/tool/frontend/src/pages/RTIDetail.tsx @@ -255,24 +255,40 @@ export function RTIDetail() {

Contact Details

-
- - {request?.receiver?.email ? ( - - {request.receiver.email} - +
+ + {request?.receiver?.emails && request.receiver.emails.length > 0 ? ( +
+ {request.receiver.emails.map((email: string, i: number) => ( + + {email} + + ))} +
) : ( -

No email address

+

No email address

)}
-
- -

- {request?.receiver?.contactNo || 'No contact number'} -

+
+ + {request?.receiver?.contactNos && request.receiver.contactNos.length > 0 ? ( +
+ {request.receiver.contactNos.map((contact: string, i: number) => ( + + {contact} + + ))} +
+ ) : ( +

No contact number

+ )}
diff --git a/tool/frontend/src/pages/Receivers.tsx b/tool/frontend/src/pages/Receivers.tsx index a7dfebb5..ba7764ec 100644 --- a/tool/frontend/src/pages/Receivers.tsx +++ b/tool/frontend/src/pages/Receivers.tsx @@ -13,7 +13,6 @@ import { TabButton } from '../components/TabButton'; import { FormLabel } from '../components/FormLabel'; import { FieldError } from '../components/FieldError'; - import { receiversService } from '../services/receiversService'; import { institutionService } from '../services/institutionService'; import { positionService } from '../services/positionService'; @@ -22,24 +21,44 @@ import { useEntityData } from '../hooks/useEntityData'; import { Column } from '../types/table'; import { useDebounce } from '../hooks/useDebounce'; +import { Regx } from "../consts/regx"; +import TagInput from '../components/TagInput'; + type TabKey = 'receivers' | 'institutions' | 'positions'; -// Validation Schemas +// define required regex +const EMAIL_RE = Regx.EMAIL; +const PHONE_RE = Regx.PHONE; +// receiver validation schema const receiverSchema = yup.object().shape({ institutionId: yup.string().required('Institution is required'), positionId: yup.string().required('Position is required'), - email: yup.string().trim().nullable().transform(v => v === '' ? null : v) - .matches(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, 'Please enter a valid email address'), - contactNo: yup.string().trim().nullable().transform(v => v === '' ? null : v) - .test('is-sl-phone', 'Please enter a valid Sri Lankan phone number (e.g. 0771234567 or +94771234567)', value => { - if (!value) return true; - return /^(?:\+94|0)[1-9][0-9]{8}$/.test(value); - }), - address: yup.string().nullable().transform(v => v === '' ? null : v), + emails: yup + .array() + .of( + yup + .string() + .matches(EMAIL_RE, 'Please enter a valid email address') + ) + .nullable(), + contactNos: yup + .array() + .of( + yup + .string() + .test('is-sl-phone', 'Please enter a valid Sri Lankan phone number (e.g. 0771234567 or +94771234567)', v => { + if (!v) return true; + return PHONE_RE.test(v); + }) + ) + .nullable(), + address: yup.string().nullable().transform(v => (v === '' ? null : v)), }).test('contact-required', 'Either Email or Contact No is required', function (value) { - if (!value.email && !value.contactNo) { - return this.createError({ path: 'email', message: 'Email or Contact No is required' }); + const hasEmails = Array.isArray(value.emails) && value.emails.length > 0; + const hasContactNos = Array.isArray(value.contactNos) && value.contactNos.length > 0; + if (!hasEmails && !hasContactNos) { + return this.createError({ path: 'emails', message: 'At least one Email or Contact No is required' }); } return true; }); @@ -48,6 +67,7 @@ const nameEntitySchema = yup.object({ name: yup.string().required('Name is required').trim(), }); + export function Receivers() { const [tab, setTab] = useState('receivers'); @@ -125,10 +145,14 @@ export function Receivers() { reset: resetReceiverForm, setValue: setReceiverValue, formState: { errors: receiverErrors } - } = useForm({ - resolver: yupResolver(receiverSchema), + } = useForm({ + resolver: yupResolver(receiverSchema) as any, defaultValues: { - institutionId: '', positionId: '', email: '', contactNo: '', address: '' + institutionId: '', + positionId: '', + emails: [] as string[], + contactNos: [] as string[], + address: '' } }); @@ -153,8 +177,26 @@ export function Receivers() { const receiverColumns: Column[] = useMemo(() => [ { header: 'Institution', cell: (r) => r.institution?.name || '-', className: 'font-medium text-gray-900' }, { header: 'Position', cell: (r) => r.position?.name || '-', className: 'text-gray-700' }, - { header: 'Email', accessor: 'email', className: 'text-gray-600' }, - { header: 'Contact No', accessor: 'contactNo', className: 'text-gray-600' }, + { + header: 'Emails', + cell: (r) => r.emails?.length + ?
+ {r.emails.map((e, i) => ( + {e} + ))} +
+ : '-', + }, + { + header: 'Contact Nos', + cell: (r) => r.contactNos?.length + ?
+ {r.contactNos.map((n, i) => ( + {n} + ))} +
+ : '-', + }, { header: 'Address', accessor: 'address', className: 'text-gray-600' }, ], []); @@ -176,20 +218,27 @@ export function Receivers() { resetReceiverForm({ institutionId: r?.institution?.id || '', positionId: r?.position?.id || '', - email: r?.email || '', - contactNo: r?.contactNo || '', + emails: r?.emails || [], + contactNos: r?.contactNos || [], address: r?.address || '' }); setReceiverModalOpen(true); }; - const onSaveReceiver = async (data: { institutionId?: string, positionId?: string, email?: string | null, contactNo?: string | null, address?: string | null }) => { + const onSaveReceiver = async (data: ReceiverFormValues) => { try { + const payload = { + institutionId: data.institutionId, + positionId: data.positionId, + emails: data.emails ?? [], + contactNos: data.contactNos ?? [], + address: data.address ?? null, + }; if (receiverEdit) { - await receiversHook.confirmUpdate(receiverEdit.id, data); + await receiversHook.confirmUpdate(receiverEdit.id, payload); toast.success('Receiver updated'); } else { - await receiversHook.confirmCreate(data); + await receiversHook.confirmCreate(payload); toast.success('Receiver created'); } setReceiverModalOpen(false); @@ -300,8 +349,6 @@ export function Receivers() { loading={(tab === 'institutions' ? (institutionsHook.isLoading || institutionsHook.isFetching) : (positionsHook.isLoading || positionsHook.isFetching)) || isAnyMutating} onPageChange={p => updateParams(tab, { page: p })} onPageSizeChange={s => updateParams(tab, { pageSize: s, page: 1 })} - // searchTerm={params[tab].search} - // onSearch={s => updateParams(tab, { search: s, page: 1 })} columns={simpleEntityColumns} onEdit={item => openNameModal(tab === 'institutions' ? 'institution' : 'position', item)} onDelete={(item: Institution | Position) => setDeleteConfirm({ id: item.id, type: tab })} @@ -368,46 +415,51 @@ export function Receivers() { />
-
- + + {/* Emails — full width */} +
+ +

Type an email and press Enter or , to add. Paste comma-separated emails to add multiple at once.

( - EMAIL_RE.test(v)} + validationMessage="Please enter a valid email address" placeholder="receiver@example.com" + hasError={!!receiverErrors.emails} + type="email" /> )} /> - +
-
- + + {/* Contact Nos — full width */} +
+ +

Type a number and press Enter or , to add.

( - { - const val = e.target.value.replace(/[^0-9+]/g, ''); - const sanitized = val.replace(/(?!^)\+/g, ''); - field.onChange(sanitized); - }} + PHONE_RE.test(v)} + validationMessage="Please enter a valid Sri Lankan phone number (e.g. 0771234567 or +94771234567)" placeholder="0xxxxxxxx or +94xxxxxxxxx" + hasError={!!receiverErrors.contactNos} + type="tel" /> )} /> - +
+
= {}; From 9e9e60e0871c5d4255cad7c3accc9f07d784db0c Mon Sep 17 00:00:00 2001 From: ChanukaUOJ Date: Mon, 25 May 2026 11:03:36 +0530 Subject: [PATCH 4/4] bot comment resolved --- tool/backend/sql/seed.sql | 2 +- tool/backend/src/models/request_models/receiver.py | 6 +++--- tool/backend/src/services/receiver_service.py | 4 ---- tool/frontend/src/data/mockData.ts | 4 ++-- tool/frontend/src/pages/Receivers.tsx | 6 ++++-- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tool/backend/sql/seed.sql b/tool/backend/sql/seed.sql index 302bd836..321a971d 100644 --- a/tool/backend/sql/seed.sql +++ b/tool/backend/sql/seed.sql @@ -45,7 +45,7 @@ INSERT INTO receivers (position_id, institution_id, emails, address, contact_nos ((SELECT id FROM positions WHERE name = 'Information Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Ministry of Health' LIMIT 1), '["io.health@gov.lk"]', NULL, '["0112444555"]'), ((SELECT id FROM positions WHERE name = 'Designated Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Department of Education' LIMIT 1), '["do.edu@gov.lk"]', NULL, '[]'), ((SELECT id FROM positions WHERE name = 'Secretary' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Central Environmental Authority' LIMIT 1), '[]', 'Colombo', '["0112888999"]'), -((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), '["dg.rda@gov.lk"]', 'Colombo', '["0112000111","0771212123","0789090987","0771212123","0789090987"]'), +((SELECT id FROM positions WHERE name = 'Director General' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Road Development Authority' LIMIT 1), '["dg.rda@gov.lk"]', 'Colombo', '["0112000111","0771212123","0789090987","0771212121","0789090988"]'), ((SELECT id FROM positions WHERE name = 'Legal Officer' LIMIT 1), (SELECT id FROM institutions WHERE name = 'Sri Lanka Police' LIMIT 1), '[]', NULL, '["0112222333"]'), ((SELECT id FROM positions WHERE name = 'Administrative Assistant' LIMIT 1), (SELECT id FROM institutions WHERE name = 'National Water Supply & Drainage Board' LIMIT 1), '["admin.nwsdb@gov.lk"]', 'Colombo', '["0112555666"]'), ((SELECT id FROM positions WHERE name = 'Research Analyst' LIMIT 1), (SELECT id FROM institutions WHERE name = 'University of Colombo' LIMIT 1), '["research.uoc@ac.lk"]', NULL, '[]'), diff --git a/tool/backend/src/models/request_models/receiver.py b/tool/backend/src/models/request_models/receiver.py index b0eaac12..f5fd4446 100644 --- a/tool/backend/src/models/request_models/receiver.py +++ b/tool/backend/src/models/request_models/receiver.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field, ConfigDict, EmailStr, model_validator -from typing import Optional, List +from typing import Optional, List, Annotated from uuid import UUID from src.core.exceptions import BadRequestException @@ -10,7 +10,7 @@ class ReceiverUpdateRequest(BaseModel): institution_id: Optional[UUID] = Field(None, alias="institutionId", description="ID of the institution") emails: Optional[List[EmailStr]] = Field(None, description="List of receiver Emails") address: Optional[str] = Field(None, description="Address of the receiver") - contact_nos: Optional[List[str]] = Field(None, alias="contactNos", description="List of receiver contact numbers") + contact_nos: Optional[List[Annotated[str, Field(pattern=r"^(?:\+94|0)\d{9}$")]]] = Field(None, alias="contactNos", description="List of receiver contact numbers") class ReceiverRequest(BaseModel): model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True, populate_by_name=True) @@ -19,7 +19,7 @@ class ReceiverRequest(BaseModel): institution_id: UUID = Field(..., alias="institutionId", description="ID of the institution") emails: Optional[List[EmailStr]] = Field(None, description="List of receiver Emails") address: Optional[str] = Field(None, description="Address of the receiver") - contact_nos: Optional[List[str]] = Field(None, alias="contactNos", description="List of receiver contact numbers") + contact_nos: Optional[List[Annotated[str, Field(pattern=r"^(?:\+94|0)\d{9}$")]]] = Field(None, alias="contactNos", description="List of receiver contact numbers") @model_validator(mode="after") def validate_email_or_contact(self): diff --git a/tool/backend/src/services/receiver_service.py b/tool/backend/src/services/receiver_service.py index 83a35c82..c9054a4f 100644 --- a/tool/backend/src/services/receiver_service.py +++ b/tool/backend/src/services/receiver_service.py @@ -144,10 +144,6 @@ def update_receiver(self, *, receiver_id: UUID, receiver_request: ReceiverUpdate except IntegrityError as e: self.session.rollback() error_msg = str(e.orig) - if "receivers_emails_key" in error_msg or "receivers.emails" in error_msg: - raise ConflictException("Email already exists for another receiver.") - if "receivers_contact_nos_key" in error_msg or "receivers.contact_nos" in error_msg: - raise ConflictException("Contact number already exists for another receiver.") # Clean up the DB error message to return it directly clean_error = error_msg.replace('\n', ' ').strip() diff --git a/tool/frontend/src/data/mockData.ts b/tool/frontend/src/data/mockData.ts index 4a62eb5d..d36b8151 100644 --- a/tool/frontend/src/data/mockData.ts +++ b/tool/frontend/src/data/mockData.ts @@ -61,7 +61,7 @@ export const mockReceivers: Receiver[] = [ institution: mockInstitutions[0], position: mockPositions[0], emails: ['pio.finance@gov.in'], - contactNos: ['011-23095228'], + contactNos: ['01123095228'], address: 'North Block, New Delhi', createdAt: new Date(), updatedAt: new Date(), @@ -71,7 +71,7 @@ export const mockReceivers: Receiver[] = [ institution: mockInstitutions[1], position: mockPositions[1], emails: ['aa.defense@gov.in'], - contactNos: ['011-23012284'], + contactNos: ['01123012284'], address: 'South Block, New Delhi', createdAt: new Date(), updatedAt: new Date(), diff --git a/tool/frontend/src/pages/Receivers.tsx b/tool/frontend/src/pages/Receivers.tsx index ba7764ec..937fc543 100644 --- a/tool/frontend/src/pages/Receivers.tsx +++ b/tool/frontend/src/pages/Receivers.tsx @@ -39,6 +39,7 @@ const receiverSchema = yup.object().shape({ .of( yup .string() + .required() .matches(EMAIL_RE, 'Please enter a valid email address') ) .nullable(), @@ -47,6 +48,7 @@ const receiverSchema = yup.object().shape({ .of( yup .string() + .required() .test('is-sl-phone', 'Please enter a valid Sri Lankan phone number (e.g. 0771234567 or +94771234567)', v => { if (!v) return true; return PHONE_RE.test(v); @@ -146,7 +148,7 @@ export function Receivers() { setValue: setReceiverValue, formState: { errors: receiverErrors } } = useForm({ - resolver: yupResolver(receiverSchema) as any, + resolver: yupResolver(receiverSchema), defaultValues: { institutionId: '', positionId: '', @@ -231,7 +233,7 @@ export function Receivers() { institutionId: data.institutionId, positionId: data.positionId, emails: data.emails ?? [], - contactNos: data.contactNos ?? [], + contactNos: data.contactNos?.map(n => n.replace(/-/g, '')) ?? [], address: data.address ?? null, }; if (receiverEdit) {