Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,53 @@ paths:
"500":
$ref: "#/components/responses/InternalError"

/api/intake-sessions/{session_id}/answers:
parameters:
- $ref: "#/components/parameters/IntakeSessionId"
post:
tags: [Intake sessions]
operationId: submitIntakeSessionAnswer
summary: Submit one intake answer and receive the next rule-based question
description: |
Only the session creator can append a field that has not already been answered. The raw
free-text answer is stored separately from its unconfirmed draft candidate field. This
endpoint does not call an AI provider, so model and template_version are null; text that
resembles an instruction is treated only as an unconfirmed answer. Closed or confirmed
sessions and duplicate field submissions return 409. Raw answer content is never copied
into audit metadata.
security:
- bearerAuth: []
x-account-types: [member]
x-data-classification: sensitive
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitIntakeAnswerRequest"
example:
field: health_status
answer: Fictional mobility note; verification is still required.
responses:
"201":
description: Draft candidate field stored and next rule-based question returned
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitIntakeAnswerResponse"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
"500":
$ref: "#/components/responses/InternalError"

/api/cases:
get:
tags: [Cases]
Expand Down Expand Up @@ -473,6 +520,14 @@ components:
schema:
type: string
format: uuid
IntakeSessionId:
name: session_id
in: path
required: true
description: 问询会话 UUID。非创建者按未找到处理,避免确认会话存在。
schema:
type: string
format: uuid

responses:
ValidationError:
Expand Down Expand Up @@ -707,6 +762,57 @@ components:
transport_ability: { type: [string, "null"], minLength: 1, description: Limit is configured by the active question definition. }
follow_up_clues: { type: [string, "null"], minLength: 1, description: Limit is configured by the active question definition. }

SubmitIntakeAnswerRequest:
type: object
additionalProperties: false
required: [field, answer]
properties:
field:
type: string
minLength: 1
description: An enabled field from the session's immutable question-set version.
answer:
type: string
minLength: 1
description: Trimmed server-side and limited by both the question configuration and the independent hard cap.

IntakeCandidateField:
type: object
additionalProperties: false
required: [field, value, source, status, generated_at, model, template_version]
properties:
field: { type: string }
value: { type: string, description: Unconfirmed candidate value; it is not a formal case fact. }
source: { type: string, enum: [family_provided, ai_extracted] }
status: { type: string, const: draft }
generated_at: { type: string, format: date-time }
model: { type: [string, "null"], description: Null for the current rule-based path. }
template_version: { type: [string, "null"], description: Null for the current rule-based path. }

SubmitIntakeAnswerResponse:
type: object
additionalProperties: false
required: [session_id, status, raw_answer, candidate_fields, missing_fields, next_question, guidance_mode, privacy_notice, updated_at]
properties:
session_id: { type: string, format: uuid }
status: { type: string, enum: [collecting, ready_for_confirmation] }
raw_answer: { type: string, description: Original free-text response, separate from candidate fields. }
candidate_fields:
type: array
minItems: 1
items:
$ref: "#/components/schemas/IntakeCandidateField"
missing_fields:
type: array
items: { type: string }
next_question:
oneOf:
- $ref: "#/components/schemas/IntakeQuestion"
- type: "null"
guidance_mode: { type: string, const: rule_based }
privacy_notice: { type: string }
updated_at: { type: string, format: date-time }

IntakeQuestion:
type: object
additionalProperties: false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS intake_session_answers;
20 changes: 20 additions & 0 deletions migration/sql/mysql/up/0013_create_intake_session_answers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE intake_session_answers (
id VARCHAR(36) PRIMARY KEY,
session_id VARCHAR(36) NOT NULL,
field_code VARCHAR(64) NOT NULL,
raw_answer TEXT NOT NULL,
candidate_value TEXT NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
generated_at VARCHAR(40) NOT NULL,
model VARCHAR(255),
template_version VARCHAR(255),
created_at VARCHAR(40) NOT NULL,
updated_at VARCHAR(40) NOT NULL,
CONSTRAINT chk_intake_session_answers_source CHECK (source IN ('family_provided', 'ai_extracted')),
CONSTRAINT chk_intake_session_answers_status CHECK (status = 'draft'),
CONSTRAINT fk_intake_session_answers_session FOREIGN KEY (session_id) REFERENCES intake_sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, field_code)
) ENGINE=InnoDB;
-- statement-break
CREATE INDEX idx_intake_session_answers_session ON intake_session_answers(session_id, created_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS intake_session_answers;
18 changes: 18 additions & 0 deletions migration/sql/postgres/up/0013_create_intake_session_answers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE intake_session_answers (
id VARCHAR(36) PRIMARY KEY,
session_id VARCHAR(36) NOT NULL,
field_code VARCHAR(64) NOT NULL,
raw_answer TEXT NOT NULL,
candidate_value TEXT NOT NULL,
source VARCHAR(32) NOT NULL CHECK (source IN ('family_provided', 'ai_extracted')),
status VARCHAR(32) NOT NULL CHECK (status = 'draft'),
generated_at VARCHAR(40) NOT NULL,
model VARCHAR(255),
template_version VARCHAR(255),
created_at VARCHAR(40) NOT NULL,
updated_at VARCHAR(40) NOT NULL,
CONSTRAINT fk_intake_session_answers_session FOREIGN KEY (session_id) REFERENCES intake_sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, field_code)
);
-- statement-break
CREATE INDEX idx_intake_session_answers_session ON intake_session_answers(session_id, created_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS intake_session_answers;
18 changes: 18 additions & 0 deletions migration/sql/sqlite/up/0013_create_intake_session_answers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE intake_session_answers (
id TEXT PRIMARY KEY NOT NULL,
session_id TEXT NOT NULL,
field_code TEXT NOT NULL,
raw_answer TEXT NOT NULL,
candidate_value TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('family_provided', 'ai_extracted')),
status TEXT NOT NULL CHECK (status = 'draft'),
generated_at TEXT NOT NULL,
model TEXT,
template_version TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES intake_sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, field_code)
);
-- statement-break
CREATE INDEX idx_intake_session_answers_session ON intake_session_answers(session_id, created_at);
2 changes: 2 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod m0009_add_learner_role;
mod m0010_create_intake_sessions;
mod m0011_create_intake_question_definitions;
mod m0012_split_account_type_and_capabilities;
mod m0013_create_intake_session_answers;

use sea_orm_migration::sea_orm::DbBackend;

Expand All @@ -33,6 +34,7 @@ impl MigratorTrait for Migrator {
Box::new(m0010_create_intake_sessions::Migration),
Box::new(m0011_create_intake_question_definitions::Migration),
Box::new(m0012_split_account_type_and_capabilities::Migration),
Box::new(m0013_create_intake_session_answers::Migration),
]
}
}
Expand Down
34 changes: 34 additions & 0 deletions migration/src/m0013_create_intake_session_answers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use sea_orm_migration::prelude::*;

use crate::{execute_script, sql_for_backend};

pub struct Migration;

impl MigrationName for Migration {
fn name(&self) -> &str {
"m0013_create_intake_session_answers"
}
}

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = sql_for_backend(
manager,
include_str!("../sql/sqlite/up/0013_create_intake_session_answers.sql"),
include_str!("../sql/postgres/up/0013_create_intake_session_answers.sql"),
include_str!("../sql/mysql/up/0013_create_intake_session_answers.sql"),
);
execute_script(manager, sql).await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = sql_for_backend(
manager,
include_str!("../sql/sqlite/down/0013_drop_intake_session_answers.sql"),
include_str!("../sql/postgres/down/0013_drop_intake_session_answers.sql"),
include_str!("../sql/mysql/down/0013_drop_intake_session_answers.sql"),
);
execute_script(manager, sql).await
}
}
34 changes: 34 additions & 0 deletions src/entities/intake_session_answers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use sea_orm::entity::prelude::*;
use serde::Serialize;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "intake_session_answers")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub session_id: String,
pub field_code: String,
pub raw_answer: String,
pub candidate_value: String,
pub source: String,
pub status: String,
pub generated_at: String,
pub model: Option<String>,
pub template_version: Option<String>,
pub created_at: String,
pub updated_at: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::intake_sessions::Entity",
from = "Column::SessionId",
to = "super::intake_sessions::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Session,
}

impl ActiveModelBehavior for ActiveModel {}
1 change: 1 addition & 0 deletions src/entities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod clue_attributions;
pub mod clues;
pub mod elder_profiles;
pub mod intake_question_definitions;
pub mod intake_session_answers;
pub mod intake_sessions;
pub mod user_global_capabilities;
pub mod users;
66 changes: 65 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize};

use crate::{
entities::{cases, clue_attributions, clues, elder_profiles, intake_sessions},
entities::{
cases, clue_attributions, clues, elder_profiles, intake_session_answers, intake_sessions,
},
roles::{AccountType, CaseRole, GlobalCapability},
};

Expand Down Expand Up @@ -60,6 +62,15 @@ pub struct CreateIntakeSessionRequest {
pub initial_answers: IntakeInitialAnswers,
}

/// A single answer is kept separate from the candidate field generated from
/// it. Both remain unconfirmed until the later explicit confirmation flow.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubmitIntakeAnswerRequest {
pub field: String,
pub answer: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct IntakeInitialAnswers {
Expand Down Expand Up @@ -116,6 +127,59 @@ impl IntakeSessionResponse {
}
}

#[derive(Clone, Debug, Serialize)]
pub struct IntakeCandidateField {
pub field: String,
pub value: String,
pub source: String,
pub status: String,
pub generated_at: String,
pub model: Option<String>,
pub template_version: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct SubmitIntakeAnswerResponse {
pub session_id: String,
pub status: String,
pub raw_answer: String,
pub candidate_fields: Vec<IntakeCandidateField>,
pub missing_fields: Vec<String>,
pub next_question: Option<IntakeQuestion>,
pub guidance_mode: String,
pub privacy_notice: String,
pub updated_at: String,
}

impl SubmitIntakeAnswerResponse {
pub fn new(
session: intake_sessions::Model,
answer: intake_session_answers::Model,
missing_fields: Vec<String>,
next_question: Option<IntakeQuestion>,
) -> Self {
Self {
session_id: session.id,
status: session.status,
raw_answer: answer.raw_answer,
candidate_fields: vec![IntakeCandidateField {
field: answer.field_code,
value: answer.candidate_value,
source: answer.source,
status: answer.status,
generated_at: answer.generated_at,
model: answer.model,
template_version: answer.template_version,
}],
missing_fields,
next_question,
guidance_mode: "rule_based".to_owned(),
privacy_notice: "Answers and candidate fields are unconfirmed drafts. They remain visible only to the session creator and are not copied into audit metadata.".to_owned(),
updated_at: session.updated_at,
}
}
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateCaseStatusRequest {
Expand Down
Loading