| @@ -0,0 +1,10 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="db-forest-configuration"> | |||
| <data version="2">. | |||
| ---------------------------------------- | |||
| 1:0:68351c5b-378b-43b9-b884-552cec2dc79f | |||
| 2:0:887ad5d3-a663-40c3-8bf7-5f0439ede4f9 | |||
| .</data> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1 @@ | |||
| -- This file should undo anything in `up.sql` | |||
| @@ -0,0 +1,7 @@ | |||
| create table if not exists amendment_paragraphs | |||
| ( | |||
| id serial primary key, | |||
| amendment_text text not null, | |||
| index integer not null, | |||
| amendment_id integer not null references amendments(id) | |||
| ) | |||
| @@ -0,0 +1 @@ | |||
| drop table if exists amendment_votes; | |||
| @@ -0,0 +1,11 @@ | |||
| -- Amendment votes schema | |||
| -- Postgres dialect | |||
| create table amendment_votes ( | |||
| id serial primary key, | |||
| amendment_id integer not null references amendments(id) on delete cascade, | |||
| user_id integer not null references users(id) on delete cascade, | |||
| in_favor boolean not null, | |||
| created_at timestamptz not null default now(), | |||
| unique (amendment_id, user_id) | |||
| ); | |||
| @@ -0,0 +1,9 @@ | |||
| drop index if exists idx_user_activity_tx_hash; | |||
| drop index if exists idx_user_activity_target_user_id; | |||
| drop index if exists idx_user_activity_proposal_id; | |||
| drop index if exists idx_user_activity_type; | |||
| drop index if exists idx_user_activity_user_occurred; | |||
| drop table if exists user_activity; | |||
| drop type if exists activity_type; | |||
| @@ -0,0 +1,82 @@ | |||
| -- User activity log used to derive participation weights | |||
| -- Postgres dialect | |||
| create type activity_type as enum ( | |||
| -- Governance: authoring and steering proposals/amendments | |||
| 'proposal_created', | |||
| 'proposal_revised', | |||
| 'proposal_withdrawn', | |||
| 'proposal_sponsored', | |||
| 'amendment_created', | |||
| 'amendment_revised', | |||
| 'amendment_withdrawn', | |||
| -- Voting | |||
| 'proposal_voted', | |||
| 'amendment_voted', | |||
| 'amendment_vote_changed', | |||
| -- Outcomes of authored work (recorded by the system against the author) | |||
| 'proposal_passed', | |||
| 'proposal_rejected', | |||
| 'amendment_approved', | |||
| 'amendment_rejected', | |||
| -- Delegation of voting power (target_user_id is the delegate) | |||
| 'delegation_granted', | |||
| 'delegation_revoked', | |||
| -- Deliberation | |||
| 'comment_created', | |||
| 'comment_edited', | |||
| 'comment_endorsed', | |||
| -- Presence | |||
| 'session_started', | |||
| 'proposal_viewed', | |||
| 'amendment_viewed', | |||
| -- Membership and on-chain stake (Stellar) | |||
| 'wallet_verified', | |||
| 'tokens_staked', | |||
| 'tokens_unstaked', | |||
| 'treasury_contributed', | |||
| -- Stewardship | |||
| 'content_flagged', | |||
| 'moderation_performed' | |||
| ); | |||
| create table user_activity ( | |||
| id serial primary key, | |||
| user_id integer not null references users(id) on delete cascade, | |||
| activity_type activity_type not null, | |||
| proposal_id integer references proposals(id) on delete set null, | |||
| amendment_id integer references amendments(id) on delete set null, | |||
| comment_id integer references comments(id) on delete set null, | |||
| -- Counterparty: delegate, endorsed comment's author, or moderated user | |||
| target_user_id integer references users(id) on delete set null, | |||
| -- On-chain amount in stroops (1 XLM = 10,000,000 stroops) | |||
| amount bigint, | |||
| tx_hash text, | |||
| occurred_at timestamptz not null default now(), | |||
| constraint user_activity_on_chain_fields check ( | |||
| activity_type not in ('tokens_staked', 'tokens_unstaked', 'treasury_contributed') | |||
| or (tx_hash is not null and amount is not null and amount > 0) | |||
| ), | |||
| constraint user_activity_delegation_target check ( | |||
| activity_type not in ('delegation_granted', 'delegation_revoked') | |||
| or target_user_id is not null | |||
| ), | |||
| constraint user_activity_no_self_target check (target_user_id is null or target_user_id <> user_id) | |||
| ); | |||
| -- Weight calculations scan a user's activity over a time window | |||
| create index idx_user_activity_user_occurred on user_activity(user_id, occurred_at); | |||
| create index idx_user_activity_type on user_activity(activity_type); | |||
| create index idx_user_activity_proposal_id on user_activity(proposal_id); | |||
| -- Credit received from others (endorsements, delegations) | |||
| create index idx_user_activity_target_user_id on user_activity(target_user_id); | |||
| -- A chain transaction may only be credited once | |||
| create unique index idx_user_activity_tx_hash on user_activity(tx_hash) where tx_hash is not null; | |||
| @@ -0,0 +1,3 @@ | |||
| drop table if exists proposal_votes; | |||
| drop type if exists vote_choice; | |||
| @@ -0,0 +1,14 @@ | |||
| -- Proposal votes schema | |||
| -- Postgres dialect | |||
| create type vote_choice as enum ('yes', 'no', 'abstain'); | |||
| create table proposal_votes ( | |||
| id serial primary key, | |||
| proposal_id integer not null references proposals(id) on delete cascade, | |||
| user_id integer not null references users(id) on delete cascade, | |||
| choice vote_choice not null, | |||
| created_at timestamptz not null default now(), | |||
| -- A user may only vote once per proposal | |||
| unique (proposal_id, user_id) | |||
| ); | |||
| @@ -0,0 +1,4 @@ | |||
| alter table proposals drop column if exists organization_id; | |||
| alter table users drop column if exists organization_id; | |||
| drop table if exists organizations; | |||
| @@ -0,0 +1,26 @@ | |||
| -- Organizations that users and proposals belong to | |||
| -- Postgres dialect | |||
| create table organizations ( | |||
| id serial primary key, | |||
| name text not null unique, | |||
| created_at timestamptz not null default now(), | |||
| updated_at timestamptz not null default now() | |||
| ); | |||
| alter table users add column organization_id integer references organizations(id); | |||
| alter table proposals add column organization_id integer references organizations(id); | |||
| -- Existing users and proposals are moved into a default organization so the columns can be made required | |||
| insert into organizations (name) | |||
| select 'Default Organization' | |||
| where exists (select 1 from users) or exists (select 1 from proposals); | |||
| update users set organization_id = (select id from organizations where name = 'Default Organization'); | |||
| update proposals set organization_id = (select id from organizations where name = 'Default Organization'); | |||
| alter table users alter column organization_id set not null; | |||
| alter table proposals alter column organization_id set not null; | |||
| create index users_organization_id_idx on users (organization_id); | |||
| create index proposals_organization_id_idx on proposals (organization_id); | |||
| @@ -0,0 +1,3 @@ | |||
| drop index if exists comments_parent_id_idx; | |||
| alter table comments drop column if exists parent_id; | |||
| @@ -0,0 +1,6 @@ | |||
| -- Allow comments to reply to other comments | |||
| -- Postgres dialect | |||
| alter table comments add column parent_id integer references comments(id) on delete cascade; | |||
| create index comments_parent_id_idx on comments (parent_id); | |||
| @@ -8,9 +8,11 @@ mod repositories; | |||
| mod utils; | |||
| mod openapi; | |||
| use crate::routes::amendment::{add_amendment, get_amendment, list_amendments}; | |||
| use crate::routes::amendment::{add_amendment, get_amendment, list_amendments, vote_amendment}; | |||
| use crate::routes::comment::{add_comment, get_comment, list_comments}; | |||
| use crate::routes::auth::{get_nonce, login, login_freighter, register}; | |||
| use crate::routes::proposal::{add_proposal, get_proposal, list_proposals, visit_proposal}; | |||
| use crate::routes::organization::{get_organization, register_organization}; | |||
| use crate::routes::proposal::{add_proposal, get_proposal, get_proposal_content, list_proposals, visit_proposal, vote_proposal, get_user_votes}; | |||
| use crate::utils::env::load_env; | |||
| use actix_web::{App, HttpResponse, HttpServer, web}; | |||
| use utoipa::OpenApi; | |||
| @@ -43,16 +45,34 @@ async fn main() -> std::io::Result<()> { | |||
| .service(add_proposal) | |||
| .service(get_proposal) | |||
| .service(visit_proposal) | |||
| .service(get_proposal_content) | |||
| .service(vote_proposal) | |||
| .service(get_user_votes) | |||
| ) | |||
| .service( | |||
| web::scope("/amendment") | |||
| .service(add_amendment) | |||
| .service(get_amendment) | |||
| .service(vote_amendment) | |||
| ) | |||
| .service( | |||
| web::scope("/amendments") | |||
| .service(list_amendments) | |||
| ) | |||
| .service( | |||
| web::scope("/comment") | |||
| .service(add_comment) | |||
| .service(get_comment) | |||
| ) | |||
| .service( | |||
| web::scope("/comments") | |||
| .service(list_comments) | |||
| ) | |||
| .service( | |||
| web::scope("/organization") | |||
| .service(register_organization) | |||
| .service(get_organization) | |||
| ) | |||
| .service( | |||
| web::scope("/auth") | |||
| .service(register) | |||
| @@ -1,3 +1,5 @@ | |||
| use utoipa::Modify; | |||
| use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; | |||
| use utoipa_gen::OpenApi; | |||
| #[derive(OpenApi)] | |||
| @@ -6,6 +8,9 @@ use utoipa_gen::OpenApi; | |||
| crate::routes::proposal::list_proposals, | |||
| crate::routes::proposal::get_proposal, | |||
| crate::routes::proposal::add_proposal, | |||
| crate::routes::proposal::get_proposal_content, | |||
| crate::routes::proposal::vote_proposal, | |||
| crate::routes::proposal::get_user_votes, | |||
| crate::routes::auth::register, | |||
| crate::routes::auth::login, | |||
| crate::routes::auth::get_nonce, | |||
| @@ -13,6 +18,12 @@ use utoipa_gen::OpenApi; | |||
| crate::routes::amendment::list_amendments, | |||
| crate::routes::amendment::add_amendment, | |||
| crate::routes::amendment::get_amendment, | |||
| crate::routes::amendment::vote_amendment, | |||
| crate::routes::comment::list_comments, | |||
| crate::routes::comment::add_comment, | |||
| crate::routes::comment::get_comment, | |||
| crate::routes::organization::register_organization, | |||
| crate::routes::organization::get_organization, | |||
| ), | |||
| components( | |||
| schemas( | |||
| @@ -22,7 +33,37 @@ use utoipa_gen::OpenApi; | |||
| crate::types::proposal::SelectableProposal, | |||
| crate::types::proposal::ProposalWithSummary, | |||
| crate::types::proposal::ProposalWithParagraphs, | |||
| crate::types::proposal::ProposalVote, | |||
| crate::types::proposal::VoteChoice, | |||
| crate::routes::proposal::VoteProposalRequest, | |||
| crate::types::proposal::UserAmendmentVote, | |||
| crate::types::proposal::UserProposalVotes, | |||
| crate::types::amendment::AmendmentWithSummary, | |||
| crate::types::amendment::Amendment, | |||
| crate::types::amendment::AmendmentVote, | |||
| crate::routes::amendment::VoteAmendmentRequest, | |||
| crate::routes::amendment::VoteAmendmentResponse, | |||
| crate::types::comment::SelectableComment, | |||
| crate::types::comment::CommentWithUser, | |||
| crate::routes::comment::AddCommentRequest, | |||
| crate::types::organization::Organization, | |||
| crate::routes::organization::RegisterOrganizationRequest, | |||
| crate::types::auth::Claims, | |||
| ), | |||
| ) | |||
| ), | |||
| modifiers(&SecurityAddon) | |||
| )] | |||
| pub struct ApiDoc; | |||
| pub struct ApiDoc; | |||
| /// Registers the `bearer_auth` scheme referenced by authenticated endpoints. | |||
| struct SecurityAddon; | |||
| impl Modify for SecurityAddon { | |||
| fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { | |||
| let components = openapi.components.get_or_insert_with(Default::default); | |||
| components.add_security_scheme( | |||
| "bearer_auth", | |||
| SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()), | |||
| ); | |||
| } | |||
| } | |||
| @@ -1,10 +1,16 @@ | |||
| use crate::db::db::establish_connection; | |||
| use crate::types::amendment::SelectableAmendment; | |||
| use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| use crate::db::db::{establish_connection, get_connection}; | |||
| use crate::types::amendment::{Amendment, AmendmentParagraph, AmendmentStatus, AmendmentVote, AmendmentWithParagraphs, AmendmentWithSummary, NewAmendmentParagraph, NewAmendmentVote, SelectableAmendment}; | |||
| use diesel::{ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| use crate::repositories::proposal::ensure_proposal_in_organization; | |||
| use crate::schema::{amendment_paragraphs, amendment_votes, amendments, proposals}; | |||
| use crate::schema::amendment_paragraphs::amendment_id; | |||
| use crate::schema::amendments::dsl::{proposal_id as proposal_id_col}; | |||
| /// Fails with `Error::NotFound` if the proposal is not in the given organization. | |||
| pub fn get_amendments_for_proposal( | |||
| proposal_id: i32, | |||
| ) -> Result<Vec<SelectableAmendment>, diesel::result::Error> { | |||
| prop_id: i32, | |||
| org_id: i32, | |||
| ) -> Result<Vec<AmendmentWithSummary>, diesel::result::Error> { | |||
| let pool = establish_connection().map_err(|_| { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::Unknown, | |||
| @@ -19,10 +25,174 @@ pub fn get_amendments_for_proposal( | |||
| ) | |||
| })?; | |||
| use crate::schema::amendments::dsl::{amendments, proposal_id as proposal_id_col}; | |||
| amendments | |||
| .filter(proposal_id_col.eq(proposal_id)) | |||
| ensure_proposal_in_organization(&mut conn, prop_id, org_id)?; | |||
| let amndments = crate::schema::amendments::dsl::amendments | |||
| .filter(proposal_id_col.eq(prop_id)) | |||
| .select(SelectableAmendment::as_select()) | |||
| .order(crate::schema::amendments::id.asc()) | |||
| .load(&mut conn) | |||
| .unwrap_or_else(|_| { | |||
| eprintln!("Could not unwrap query"); | |||
| Vec::new() | |||
| }); | |||
| let result: Result<Vec<AmendmentWithSummary>, diesel::result::Error> = amndments | |||
| .into_iter() | |||
| .map(|amendment| { | |||
| let summ = amendment_paragraphs::table | |||
| .filter(amendment_id.eq(amendment.id)) | |||
| .order(amendment_paragraphs::index.asc()) | |||
| .select(amendment_paragraphs::amendment_text) | |||
| .first::<String>(&mut conn)?; | |||
| Ok(AmendmentWithSummary { | |||
| id: amendment.id, | |||
| name: amendment.name, | |||
| creator: amendment.creator, | |||
| is_current: true, | |||
| created_at: amendment.created_at, | |||
| updated_at: amendment.updated_at, | |||
| status: amendment.status, | |||
| summary: Some(summ), | |||
| proposal_id: prop_id, | |||
| }) | |||
| }) | |||
| .collect(); | |||
| result | |||
| } | |||
| pub fn add_amendment( | |||
| prop_id: i32, | |||
| amendment_name: String, | |||
| amendment_creator: String, | |||
| paragraphs: Vec<String>, | |||
| ) -> Result<SelectableAmendment, diesel::result::Error> { | |||
| let mut conn = get_connection().expect("Failed to get database connection"); | |||
| let amendment = Amendment { | |||
| proposal_id: prop_id, | |||
| name: amendment_name, | |||
| creator: amendment_creator, | |||
| is_current: true, | |||
| created_at: Default::default(), | |||
| updated_at: Default::default(), | |||
| status: AmendmentStatus::Proposed, | |||
| }; | |||
| let result = diesel::insert_into(amendments::table) | |||
| .values(amendment) | |||
| .returning(amendments::all_columns) | |||
| .get_result::<SelectableAmendment>(&mut conn) | |||
| .map_err(|e| diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::Unknown, Box::new(e.to_string())))?; | |||
| save_amendment_paragraphs(result.id, paragraphs)?; | |||
| Ok(result) | |||
| } | |||
| pub fn save_amendment_paragraphs(amend_id: i32, amendment_paragraphs: Vec<String>) -> Result<(), diesel::result::Error> { | |||
| if amendment_paragraphs.is_empty() { | |||
| return Ok(()); | |||
| } | |||
| let mut conn = get_connection().expect("Failed to get database connection"); | |||
| let paragraphs: Vec<NewAmendmentParagraph> = amendment_paragraphs | |||
| .into_iter() | |||
| .enumerate() | |||
| .map(|(ind, para)| NewAmendmentParagraph { | |||
| amendment_id: amend_id, | |||
| index: ind as i32, | |||
| amendment_text: para, | |||
| }) | |||
| .collect(); | |||
| diesel::insert_into(amendment_paragraphs::table) | |||
| .values(¶graphs) | |||
| .execute(&mut conn) | |||
| .map(|_| ()) | |||
| .map_err(|e| diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::Unknown, Box::new(e.to_string()))) | |||
| } | |||
| pub fn get_amendment_paragraphs( | |||
| amend_id: i32, | |||
| ) -> Result<Vec<AmendmentParagraph>, diesel::result::Error> { | |||
| let mut conn = get_connection().expect("Failed to get database connection"); | |||
| amendment_paragraphs::table | |||
| .filter(amendment_paragraphs::amendment_id.eq(amend_id)) | |||
| .order(amendment_paragraphs::index.asc()) | |||
| .select(( | |||
| amendment_paragraphs::id, | |||
| amendment_paragraphs::amendment_id, | |||
| amendment_paragraphs::index, | |||
| amendment_paragraphs::amendment_text, | |||
| )) | |||
| .load::<AmendmentParagraph>(&mut conn) | |||
| } | |||
| /// Records a user's vote on an amendment. A user may only vote once per amendment; | |||
| /// subsequent votes are ignored and `Ok(None)` is returned. | |||
| /// Fails with `Error::NotFound` if the amendment's proposal is not in the voter's organization. | |||
| pub fn add_amendment_vote( | |||
| amend_id: i32, | |||
| voter_id: i32, | |||
| org_id: i32, | |||
| in_favor: bool, | |||
| ) -> Result<Option<AmendmentVote>, diesel::result::Error> { | |||
| let mut conn = get_connection().expect("Failed to get database connection"); | |||
| amendments::table | |||
| .inner_join(proposals::table.on(proposals::id.eq(amendments::proposal_id))) | |||
| .filter(amendments::id.eq(amend_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .select(amendments::id) | |||
| .first::<i32>(&mut conn)?; | |||
| let vote = NewAmendmentVote { | |||
| amendment_id: amend_id, | |||
| user_id: voter_id, | |||
| in_favor, | |||
| }; | |||
| diesel::insert_into(amendment_votes::table) | |||
| .values(&vote) | |||
| .on_conflict((amendment_votes::amendment_id, amendment_votes::user_id)) | |||
| .do_nothing() | |||
| .returning(AmendmentVote::as_returning()) | |||
| .get_result::<AmendmentVote>(&mut conn) | |||
| .optional() | |||
| } | |||
| pub fn get_amendment_by_id(amend_id: i32, org_id: i32) -> Result<AmendmentWithParagraphs, diesel::result::Error> { | |||
| let mut conn = get_connection().expect("Failed to get database connection"); | |||
| let amendment = amendments::table | |||
| .inner_join(proposals::table.on(proposals::id.eq(amendments::proposal_id))) | |||
| .filter(amendments::id.eq(amend_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .select(SelectableAmendment::as_select()) | |||
| .get_result::<SelectableAmendment>(&mut conn) | |||
| .map_err(|e| diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::Unknown, Box::new(e.to_string()))); | |||
| match amendment { | |||
| Ok(a) => { | |||
| let paragraphs = get_amendment_paragraphs(amend_id)?; | |||
| Ok(AmendmentWithParagraphs { | |||
| id: a.id, | |||
| name: a.name, | |||
| status: a.status, | |||
| creator: a.creator, | |||
| is_current: a.is_current, | |||
| created_at: a.created_at, | |||
| updated_at: a.updated_at, | |||
| proposal_id: a.proposal_id, | |||
| paragraphs, | |||
| }) | |||
| }, | |||
| Err(_) => return Err(diesel::result::Error::NotFound) | |||
| } | |||
| } | |||
| @@ -1,17 +1,91 @@ | |||
| use crate::db::db::get_connection; | |||
| use crate::schema::comments::dsl::*; | |||
| use crate::types::comment::NewComment; | |||
| use diesel::RunQueryDsl; | |||
| pub(crate) fn create_comment(cont: String, prop_id: i32, usr_id: i32) { | |||
| let mut conn = get_connection().expect("Failed to establish connection"); | |||
| let new_comment = NewComment { | |||
| content: cont, | |||
| proposal_id: prop_id, | |||
| user_id: usr_id, | |||
| }; | |||
| diesel::insert_into(comments) | |||
| use crate::schema::{comments, proposals, users}; | |||
| use crate::types::comment::{CommentWithUser, NewComment, SelectableComment}; | |||
| use diesel::{ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| fn connection_error() -> diesel::result::Error { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::UnableToSendCommand, | |||
| Box::new("Failed to get database connection".to_string()), | |||
| ) | |||
| } | |||
| pub(crate) fn create_comment(cont: String, prop_id: i32, usr_id: i32, par_id: Option<i32>) -> Result<SelectableComment, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| let new_comment = NewComment::new(cont, prop_id, usr_id, par_id); | |||
| diesel::insert_into(comments::table) | |||
| .values(&new_comment) | |||
| .execute(&mut conn) | |||
| .expect("Error saving new comment"); | |||
| } | |||
| .returning(SelectableComment::as_returning()) | |||
| .get_result::<SelectableComment>(&mut conn) | |||
| } | |||
| /// Whether a current comment with the given id exists on the given proposal. | |||
| pub(crate) fn comment_exists_on_proposal(comm_id: i32, prop_id: i32) -> Result<bool, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| diesel::select(diesel::dsl::exists( | |||
| comments::table | |||
| .filter(comments::id.eq(comm_id)) | |||
| .filter(comments::proposal_id.eq(prop_id)) | |||
| .filter(comments::is_current.eq(true)), | |||
| )) | |||
| .get_result::<bool>(&mut conn) | |||
| } | |||
| type CommentUserRow = (SelectableComment, Option<String>, Option<String>); | |||
| fn into_comment_with_user((c, uname, fname): CommentUserRow) -> CommentWithUser { | |||
| CommentWithUser { | |||
| id: c.id, | |||
| content: c.content, | |||
| is_current: c.is_current, | |||
| proposal_id: c.proposal_id, | |||
| created_at: c.created_at, | |||
| updated_at: c.updated_at, | |||
| user_id: c.user_id, | |||
| parent_id: c.parent_id, | |||
| username: uname, | |||
| full_name: fname, | |||
| } | |||
| } | |||
| /// Only returns comments on proposals in the given organization. | |||
| pub fn get_comments_for_proposal(prop_id: i32, org_id: i32) -> Result<Vec<CommentWithUser>, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| let rows = comments::table | |||
| .inner_join(proposals::table.on(proposals::id.eq(comments::proposal_id))) | |||
| .left_join(users::table.on(comments::user_id.eq(users::id.nullable()))) | |||
| .filter(comments::proposal_id.eq(prop_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .filter(comments::is_current.eq(true)) | |||
| .order(comments::created_at.asc()) | |||
| .select(( | |||
| SelectableComment::as_select(), | |||
| users::username.nullable(), | |||
| users::full_name.nullable(), | |||
| )) | |||
| .load::<CommentUserRow>(&mut conn)?; | |||
| Ok(rows.into_iter().map(into_comment_with_user).collect()) | |||
| } | |||
| /// Fails with `Error::NotFound` if the comment's proposal is not in the given organization. | |||
| pub fn get_comment_by_id(comm_id: i32, org_id: i32) -> Result<CommentWithUser, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| comments::table | |||
| .inner_join(proposals::table.on(proposals::id.eq(comments::proposal_id))) | |||
| .left_join(users::table.on(comments::user_id.eq(users::id.nullable()))) | |||
| .filter(comments::id.eq(comm_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .select(( | |||
| SelectableComment::as_select(), | |||
| users::username.nullable(), | |||
| users::full_name.nullable(), | |||
| )) | |||
| .get_result::<CommentUserRow>(&mut conn) | |||
| .map(into_comment_with_user) | |||
| } | |||
| @@ -2,4 +2,6 @@ pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod user; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod visit; | |||
| pub(crate) mod visit; | |||
| pub(crate) mod user_activity; | |||
| pub(crate) mod organization; | |||
| @@ -0,0 +1,30 @@ | |||
| use crate::db::db::get_connection; | |||
| use crate::schema::organizations; | |||
| use crate::types::organization::{NewOrganization, Organization}; | |||
| use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| fn connection_error() -> diesel::result::Error { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::UnableToSendCommand, | |||
| Box::new("Failed to get database connection".to_string()), | |||
| ) | |||
| } | |||
| /// Registers a new organization. Fails with `DatabaseErrorKind::UniqueViolation` if the name is taken. | |||
| pub fn create_organization(org_name: String) -> Result<Organization, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| diesel::insert_into(organizations::table) | |||
| .values(&NewOrganization { name: org_name }) | |||
| .returning(Organization::as_returning()) | |||
| .get_result(&mut conn) | |||
| } | |||
| pub fn get_organization_by_id(org_id: i32) -> Result<Organization, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| organizations::table | |||
| .filter(organizations::id.eq(org_id)) | |||
| .select(Organization::as_select()) | |||
| .first(&mut conn) | |||
| } | |||
| @@ -1,18 +1,29 @@ | |||
| use actix_web::get; | |||
| use diesel::prelude::*; | |||
| use diesel::result::{DatabaseErrorKind, Error}; | |||
| use crate::db::db::{establish_connection, get_connection}; | |||
| use crate::schema::{proposal_paragraphs, proposals}; | |||
| use crate::types::proposal::{ | |||
| Proposal, ProposalParagraph, ProposalParagraphRequest, ProposalWithParagraphs, | |||
| ProposalWithSummary, SelectableProposal, | |||
| }; | |||
| use crate::db::db::{establish_connection, get_connection, DbConn}; | |||
| use crate::schema::{amendment_votes, amendments, proposal_paragraphs, proposal_votes, proposals, user_activity}; | |||
| use crate::types::amendment::AmendmentVote; | |||
| use crate::types::proposal::{NewProposalVote, Proposal, ProposalParagraph, ProposalParagraphInsert, ProposalParagraphRequest, ProposalVote, ProposalWithParagraphs, ProposalWithSummary, SelectableProposal, UserAmendmentVote, UserProposalVotes, VoteChoice}; | |||
| use crate::types::user_activity::{ActivityType, NewUserActivity}; | |||
| fn db_error(message: impl Into<String>) -> Error { | |||
| Error::DatabaseError(DatabaseErrorKind::Unknown, Box::new(message.into())) | |||
| } | |||
| pub fn paginate_proposals(offset: i64, limit: i64) -> Result<Vec<ProposalWithSummary>, Error> { | |||
| /// Returns `Error::NotFound` unless the proposal exists and belongs to the given organization, | |||
| /// so proposals from other organizations are indistinguishable from missing ones. | |||
| pub fn ensure_proposal_in_organization(conn: &mut DbConn, prop_id: i32, org_id: i32) -> Result<(), Error> { | |||
| proposals::table | |||
| .filter(proposals::id.eq(prop_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .select(proposals::id) | |||
| .first::<i32>(conn) | |||
| .map(|_| ()) | |||
| } | |||
| pub fn paginate_proposals(org_id: i32, offset: i64, limit: i64) -> Result<Vec<ProposalWithSummary>, Error> { | |||
| let pool = establish_connection() | |||
| .map_err(|_| db_error("Failed to establish database connection"))?; | |||
| let mut conn = pool | |||
| @@ -20,6 +31,7 @@ pub fn paginate_proposals(offset: i64, limit: i64) -> Result<Vec<ProposalWithSum | |||
| .map_err(|_| db_error("Failed to get database connection from pool"))?; | |||
| let props = proposals::table | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .offset(offset) | |||
| .limit(limit) | |||
| .order(proposals::created_at.desc()) | |||
| @@ -51,47 +63,38 @@ pub fn paginate_proposals(offset: i64, limit: i64) -> Result<Vec<ProposalWithSum | |||
| .collect() | |||
| } | |||
| pub fn get_proposal(prop_id: i32) -> Result<ProposalWithParagraphs, Error> { | |||
| pub fn get_proposal(prop_id: i32, org_id: i32) -> Result<ProposalWithParagraphs, Error> { | |||
| let mut conn = get_connection().expect("Could not get database connection"); | |||
| let proposal = proposals::table | |||
| .filter(proposals::id.eq(prop_id)) | |||
| .first::<SelectableProposal>(&mut conn) | |||
| .unwrap_or_else(|_| { | |||
| eprintln!("Could not unwrap proposal data"); | |||
| SelectableProposal { | |||
| id: 0, | |||
| name: String::new(), | |||
| creator: String::new(), | |||
| created_at: Default::default(), | |||
| updated_at: Default::default(), | |||
| } | |||
| }); | |||
| let proposal_with_paragraphs = get_proposal_with_paragraphs(&mut conn, prop_id, org_id)?; | |||
| let paragraphs = proposal_paragraphs::table | |||
| .filter(proposal_paragraphs::proposal_id.eq(prop_id)) | |||
| .select(( | |||
| proposal_paragraphs::proposal_id, | |||
| proposal_paragraphs::paragraph_text, | |||
| proposal_paragraphs::index, | |||
| )) | |||
| .load::<ProposalParagraph>(&mut conn) | |||
| .unwrap_or_else(|_| { | |||
| eprintln!("Could not unwrap query"); | |||
| Vec::new() | |||
| }); | |||
| Ok(ProposalWithParagraphs { | |||
| id: prop_id, | |||
| name: proposal_with_paragraphs.name, | |||
| creator: proposal_with_paragraphs.creator, | |||
| created_at: proposal_with_paragraphs.created_at, | |||
| updated_at: proposal_with_paragraphs.updated_at, | |||
| paragraphs: proposal_with_paragraphs.paragraphs, | |||
| }) | |||
| } | |||
| pub fn get_proposal_content(prop_id: i32, org_id: i32) -> Result<ProposalWithParagraphs, Error> { | |||
| let mut conn = get_connection() | |||
| .expect("Failed to establish database connection"); | |||
| let proposal_with_paragraphs = get_proposal_with_paragraphs(&mut conn, prop_id, org_id)?; | |||
| Ok(ProposalWithParagraphs { | |||
| id: prop_id, | |||
| name: proposal.name, | |||
| creator: proposal.creator, | |||
| created_at: proposal.created_at, | |||
| updated_at: proposal.updated_at, | |||
| paragraphs, | |||
| name: proposal_with_paragraphs.name, | |||
| creator: proposal_with_paragraphs.creator, | |||
| created_at: proposal_with_paragraphs.created_at, | |||
| updated_at: proposal_with_paragraphs.updated_at, | |||
| paragraphs: proposal_with_paragraphs.paragraphs, | |||
| }) | |||
| } | |||
| pub fn save_proposal(proposal_name: String, proposal_creator: String) -> Result<i32, Error> { | |||
| pub fn save_proposal(proposal_name: String, proposal_creator: String, org_id: i32) -> Result<i32, Error> { | |||
| let mut conn = get_connection().expect("Could not get database connection"); | |||
| let proposal = Proposal { | |||
| @@ -99,6 +102,7 @@ pub fn save_proposal(proposal_name: String, proposal_creator: String) -> Result< | |||
| creator: proposal_creator, | |||
| created_at: Default::default(), | |||
| updated_at: Default::default(), | |||
| organization_id: org_id, | |||
| }; | |||
| diesel::insert_into(proposals::table) | |||
| @@ -111,9 +115,9 @@ pub fn save_proposal(proposal_name: String, proposal_creator: String) -> Result< | |||
| pub fn save_proposal_paragraphs(prop_id: i32, paragraphs: Vec<ProposalParagraphRequest>) { | |||
| let mut conn = get_connection().expect("Could not get database connection"); | |||
| let paragraph_items: Vec<ProposalParagraph> = paragraphs | |||
| let paragraph_items: Vec<ProposalParagraphInsert> = paragraphs | |||
| .into_iter() | |||
| .map(|p| ProposalParagraph { | |||
| .map(|p| ProposalParagraphInsert { | |||
| proposal_id: prop_id, | |||
| paragraph_text: p.paragraph_text, | |||
| index: p.index, | |||
| @@ -126,3 +130,103 @@ pub fn save_proposal_paragraphs(prop_id: i32, paragraphs: Vec<ProposalParagraphR | |||
| .map_err(|e| db_error(format!("Failed to save proposal paragraphs: {e}"))) | |||
| .expect("Could not save proposal paragraphs"); | |||
| } | |||
| /// Records a user's vote on a proposal along with the corresponding activity entry. | |||
| /// A user may only vote once per proposal; a repeat vote fails with | |||
| /// `DatabaseErrorKind::UniqueViolation` and nothing is written. | |||
| /// Fails with `Error::NotFound` if the proposal is not in the voter's organization. | |||
| pub fn add_proposal_vote(prop_id: i32, voter_id: i32, org_id: i32, choice: VoteChoice) -> Result<ProposalVote, Error> { | |||
| let mut conn = get_connection() | |||
| .map_err(|_| db_error("Failed to get database connection"))?; | |||
| let vote = NewProposalVote { | |||
| proposal_id: prop_id, | |||
| user_id: voter_id, | |||
| choice, | |||
| }; | |||
| conn.transaction(|conn| { | |||
| ensure_proposal_in_organization(conn, prop_id, org_id)?; | |||
| let saved = diesel::insert_into(proposal_votes::table) | |||
| .values(&vote) | |||
| .returning(ProposalVote::as_returning()) | |||
| .get_result::<ProposalVote>(conn)?; | |||
| diesel::insert_into(user_activity::table) | |||
| .values(NewUserActivity::new(voter_id, ActivityType::ProposalVoted).with_proposal(prop_id)) | |||
| .execute(conn)?; | |||
| Ok(saved) | |||
| }) | |||
| } | |||
| /// Fetches a user's vote on a proposal along with their vote on each of its amendments. | |||
| /// Fails with `Error::NotFound` if the proposal does not exist in the voter's organization. | |||
| pub fn get_user_votes(prop_id: i32, voter_id: i32, org_id: i32) -> Result<UserProposalVotes, Error> { | |||
| let mut conn = get_connection() | |||
| .map_err(|_| db_error("Failed to get database connection"))?; | |||
| ensure_proposal_in_organization(&mut conn, prop_id, org_id)?; | |||
| let proposal_vote = proposal_votes::table | |||
| .filter(proposal_votes::proposal_id.eq(prop_id)) | |||
| .filter(proposal_votes::user_id.eq(voter_id)) | |||
| .select(ProposalVote::as_select()) | |||
| .first::<ProposalVote>(&mut conn) | |||
| .optional()?; | |||
| let amendment_votes = amendments::table | |||
| .left_join( | |||
| amendment_votes::table.on(amendment_votes::amendment_id | |||
| .eq(amendments::id) | |||
| .and(amendment_votes::user_id.eq(voter_id))), | |||
| ) | |||
| .filter(amendments::proposal_id.eq(prop_id)) | |||
| .order(amendments::id.asc()) | |||
| .select((amendments::id, Option::<AmendmentVote>::as_select())) | |||
| .load::<(i32, Option<AmendmentVote>)>(&mut conn)? | |||
| .into_iter() | |||
| .map(|(amendment_id, vote)| UserAmendmentVote { amendment_id, vote }) | |||
| .collect(); | |||
| Ok(UserProposalVotes { | |||
| proposal_id: prop_id, | |||
| user_id: voter_id, | |||
| proposal_vote, | |||
| amendment_votes, | |||
| }) | |||
| } | |||
| fn get_proposal_with_paragraphs(conn: &mut DbConn, prop_id: i32, org_id: i32) -> Result<ProposalWithParagraphs, Error> { | |||
| let proposal = proposals::table | |||
| .filter(proposals::id.eq(prop_id)) | |||
| .filter(proposals::organization_id.eq(org_id)) | |||
| .select(SelectableProposal::as_select()) | |||
| .first::<SelectableProposal>(conn)?; | |||
| let paragraphs = proposal_paragraphs::table | |||
| .filter(proposal_paragraphs::proposal_id.eq(prop_id)) | |||
| .select(( | |||
| proposal_paragraphs::id, | |||
| proposal_paragraphs::proposal_id, | |||
| proposal_paragraphs::paragraph_text, | |||
| proposal_paragraphs::index, | |||
| )) | |||
| .load::<ProposalParagraph>(conn) | |||
| .unwrap_or_else(|_| { | |||
| eprintln!("Could not unwrap query"); | |||
| Vec::new() | |||
| }); | |||
| Ok( | |||
| ProposalWithParagraphs { | |||
| id: proposal.id, | |||
| name: proposal.name, | |||
| creator: proposal.creator, | |||
| created_at: proposal.created_at, | |||
| updated_at: proposal.updated_at, | |||
| paragraphs, | |||
| } | |||
| ) | |||
| } | |||
| @@ -3,7 +3,7 @@ use diesel::RunQueryDsl; | |||
| use diesel::pg::PgConnection; | |||
| use diesel::r2d2; | |||
| use serde::Serialize; | |||
| use utoipa_gen::ToSchema; | |||
| use crate::db::db::establish_connection; | |||
| use crate::schema::users; | |||
| use crate::schema::users::dsl::*; | |||
| @@ -13,16 +13,18 @@ use crate::schema::users::dsl::*; | |||
| pub struct NewUserInsert { | |||
| pub username: String, | |||
| pub password_hash: String, | |||
| pub stellar_address: String, | |||
| pub full_name: Option<String>, | |||
| pub email: Option<String>, | |||
| pub organization_id: i32, | |||
| } | |||
| #[derive(Serialize)] | |||
| #[derive(Serialize, ToSchema)] | |||
| pub struct RegisteredUser { | |||
| pub id: i32, | |||
| pub username: String, | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| pub organization_id: i32, | |||
| } | |||
| #[derive(Queryable)] | |||
| @@ -34,6 +36,7 @@ pub struct UserAuth { | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| pub is_active: bool, | |||
| pub organization_id: i32, | |||
| } | |||
| #[derive(Queryable, Serialize)] | |||
| @@ -44,6 +47,7 @@ pub struct UserForAuth { | |||
| pub stellar_address: String, | |||
| pub email: Option<String>, | |||
| pub is_active: bool, | |||
| pub organization_id: i32, | |||
| } | |||
| fn get_conn() -> Result<r2d2::PooledConnection<r2d2::ConnectionManager<PgConnection>>, diesel::result::Error> { | |||
| @@ -69,13 +73,14 @@ pub(crate) fn create_user( | |||
| diesel::insert_into(users::table) | |||
| .values(&new_user) | |||
| .returning((id, username, stellar_address, email)) | |||
| .get_result::<(i32, String, String, Option<String>)>(&mut conn) | |||
| .map(|(uid, uname, saddr, mail)| RegisteredUser { | |||
| .returning((id, username, stellar_address, email, organization_id)) | |||
| .get_result::<(i32, String, String, Option<String>, i32)>(&mut conn) | |||
| .map(|(uid, uname, saddr, mail, org_id)| RegisteredUser { | |||
| id: uid, | |||
| username: uname, | |||
| stellar_address: saddr, | |||
| email: mail, | |||
| organization_id: org_id, | |||
| }) | |||
| } | |||
| @@ -84,7 +89,7 @@ pub(crate) fn find_user_by_username(uname: &str) -> Result<UserAuth, diesel::res | |||
| users | |||
| .filter(username.eq(uname)) | |||
| .select((id, username, full_name, password_hash, stellar_address, email, is_active)) | |||
| .select((id, username, full_name, password_hash, stellar_address, email, is_active, organization_id)) | |||
| .first::<UserAuth>(&mut conn) | |||
| } | |||
| @@ -93,7 +98,7 @@ pub(crate) fn get_user_for_auth_by_stellar(addr: &str) -> Result<UserForAuth, di | |||
| users | |||
| .filter(stellar_address.eq(addr)) | |||
| .select((id, full_name, username, stellar_address, email, is_active)) | |||
| .select((id, full_name, username, stellar_address, email, is_active, organization_id)) | |||
| .first::<UserForAuth>(&mut conn) | |||
| } | |||
| @@ -102,6 +107,6 @@ pub(crate) fn get_user_for_auth_by_email(mail: &str) -> Result<UserForAuth, dies | |||
| users | |||
| .filter(email.eq(mail)) | |||
| .select((id, full_name, username, stellar_address, email, is_active)) | |||
| .select((id, full_name, username, stellar_address, email, is_active, organization_id)) | |||
| .first::<UserForAuth>(&mut conn) | |||
| } | |||
| @@ -0,0 +1,57 @@ | |||
| use crate::db::db::get_connection; | |||
| use crate::schema::user_activity; | |||
| use crate::types::user_activity::{ActivityCount, ActivityType, NewUserActivity, UserActivity}; | |||
| use chrono::{DateTime, Utc}; | |||
| use diesel::dsl::count_star; | |||
| use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; | |||
| fn connection_error() -> diesel::result::Error { | |||
| diesel::result::Error::DatabaseError( | |||
| diesel::result::DatabaseErrorKind::UnableToSendCommand, | |||
| Box::new("Failed to get database connection".to_string()), | |||
| ) | |||
| } | |||
| pub fn record_activity(activity: NewUserActivity) -> Result<UserActivity, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| diesel::insert_into(user_activity::table) | |||
| .values(&activity) | |||
| .returning(UserActivity::as_returning()) | |||
| .get_result(&mut conn) | |||
| } | |||
| /// Per-type activity counts for a user since the given time, used to compute participation weights. | |||
| pub fn activity_counts_since( | |||
| usr_id: i32, | |||
| since: DateTime<Utc>, | |||
| ) -> Result<Vec<ActivityCount>, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| let rows: Vec<(ActivityType, i64)> = user_activity::table | |||
| .filter(user_activity::user_id.eq(usr_id)) | |||
| .filter(user_activity::occurred_at.ge(since)) | |||
| .group_by(user_activity::activity_type) | |||
| .select((user_activity::activity_type, count_star())) | |||
| .load(&mut conn)?; | |||
| Ok(rows | |||
| .into_iter() | |||
| .map(|(activity_type, count)| ActivityCount { activity_type, count }) | |||
| .collect()) | |||
| } | |||
| /// Number of distinct proposals a user has engaged with since the given time. | |||
| pub fn distinct_proposals_since( | |||
| usr_id: i32, | |||
| since: DateTime<Utc>, | |||
| ) -> Result<i64, diesel::result::Error> { | |||
| let mut conn = get_connection().map_err(|_| connection_error())?; | |||
| user_activity::table | |||
| .filter(user_activity::user_id.eq(usr_id)) | |||
| .filter(user_activity::occurred_at.ge(since)) | |||
| .filter(user_activity::proposal_id.is_not_null()) | |||
| .select(diesel::dsl::count_distinct(user_activity::proposal_id)) | |||
| .first(&mut conn) | |||
| } | |||
| @@ -1,11 +1,15 @@ | |||
| use crate::repositories::amendment::get_amendments_for_proposal; | |||
| use crate::types::amendment::AmendmentError; | |||
| use crate::repositories::amendment; | |||
| use crate::repositories::amendment::{get_amendment_by_id, get_amendments_for_proposal}; | |||
| use crate::types::amendment::{AmendmentError, AmendmentVote, AmendmentWithParagraphs, AmendmentWithSummary, SelectableAmendment}; | |||
| use crate::utils::auth::AuthenticatedUser; | |||
| use actix_web::{HttpResponse, get, post, web}; | |||
| use diesel::result::DatabaseErrorKind; | |||
| use diesel::result::Error::{DatabaseError, NotFound}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::ToSchema; | |||
| use utoipa_gen::{IntoParams, ToSchema}; | |||
| #[derive(Deserialize)] | |||
| #[derive(Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ListAmendmentsRequest { | |||
| pub proposal_id: i32, | |||
| @@ -14,16 +18,21 @@ pub struct ListAmendmentsRequest { | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/amendments/list", | |||
| params(ListAmendmentsRequest), | |||
| responses( | |||
| (status = 200, description = "List of amendments for a proposal"), | |||
| (status = 200, description = "List of amendments for a proposal", body = Vec<AmendmentWithSummary>), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Proposal not found"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "amendment" | |||
| )] | |||
| #[get("list")] | |||
| async fn list_amendments(params: web::Query<ListAmendmentsRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| match get_amendments_for_proposal(params.proposal_id) { | |||
| Ok(amendments) => Ok(HttpResponse::Ok().json(json!({"amendments": amendments}))), | |||
| async fn list_amendments(user: AuthenticatedUser, params: web::Query<ListAmendmentsRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| match get_amendments_for_proposal(params.proposal_id, user.organization_id) { | |||
| Ok(amendments) => Ok(HttpResponse::Ok().json(amendments)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Proposal not found"}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": err.to_string()}) | |||
| )) | |||
| @@ -35,7 +44,7 @@ async fn list_amendments(params: web::Query<ListAmendmentsRequest>) -> Result<Ht | |||
| pub struct AddAmendmentRequest { | |||
| pub proposal_id: i32, | |||
| pub name: String, | |||
| pub content: String, | |||
| pub paragraphs: Vec<String>, | |||
| pub creator: String, | |||
| } | |||
| @@ -44,7 +53,7 @@ pub struct AddAmendmentRequest { | |||
| path = "/api/v1/amendment/add", | |||
| request_body = AddAmendmentRequest, | |||
| responses( | |||
| (status = 200, description = "CID of the added amendment"), | |||
| (status = 200, description = "The new amendment", body = SelectableAmendment), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| @@ -61,15 +70,15 @@ async fn add_amendment(params: web::Json<AddAmendmentRequest>) -> Result<HttpRes | |||
| if req.name.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Name is required"}))); | |||
| } | |||
| if req.content.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Content is required"}))); | |||
| if req.paragraphs.is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Paragraphs are required"}))); | |||
| } | |||
| if req.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| match create_and_store_amendment(req).await { | |||
| Ok(cid) => Ok(HttpResponse::Ok().json(json!({"cid": cid}))), | |||
| Ok(amendment) => Ok(HttpResponse::Ok().json(amendment)), | |||
| Err(err) => { | |||
| match err { | |||
| AmendmentError::SerializationError(e) => Ok(HttpResponse::BadRequest().json( | |||
| @@ -83,7 +92,7 @@ async fn add_amendment(params: web::Json<AddAmendmentRequest>) -> Result<HttpRes | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[derive(Serialize, Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetAmendmentRequest { | |||
| pub amendment_id: i32, | |||
| @@ -92,20 +101,87 @@ pub struct GetAmendmentRequest { | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/amendment/get", | |||
| params(GetAmendmentRequest), | |||
| responses( | |||
| (status = 200, description = "Amendment details"), | |||
| (status = 200, description = "Amendment details", body = AmendmentWithParagraphs), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Amendment not found"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "amendment" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_amendment(request: web::Query<GetAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // TODO: Implement actual logic to fetch an amendment by ID | |||
| Ok(HttpResponse::Ok().json(json!({"amendment": "placeholder"}))) | |||
| async fn get_amendment(user: AuthenticatedUser, request: web::Query<GetAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| if request.amendment_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "amendmentId must be a positive integer"}))); | |||
| } | |||
| match get_amendment_by_id(request.amendment_id, user.organization_id) { | |||
| Ok(amendment) => Ok(HttpResponse::Ok().json(amendment)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Amendment not found"}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to get amendment: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct VoteAmendmentRequest { | |||
| pub amendment_id: i32, | |||
| pub in_favor: bool, | |||
| } | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct VoteAmendmentResponse { | |||
| /// False if the user had already voted on this amendment, in which case nothing was changed | |||
| pub recorded: bool, | |||
| pub vote: Option<AmendmentVote>, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/amendment/vote", | |||
| request_body = VoteAmendmentRequest, | |||
| responses( | |||
| (status = 200, description = "Vote result; `recorded` is false if the user already voted", body = VoteAmendmentResponse), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Amendment not found in the current user's organization"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "amendment" | |||
| )] | |||
| #[post("vote")] | |||
| async fn vote_amendment(user: AuthenticatedUser, params: web::Json<VoteAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = params.into_inner(); | |||
| if req.amendment_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "amendmentId must be a positive integer"}))); | |||
| } | |||
| match amendment::add_amendment_vote(req.amendment_id, user.user_id, user.organization_id, req.in_favor) { | |||
| Ok(vote) => Ok(HttpResponse::Ok().json(VoteAmendmentResponse { | |||
| recorded: vote.is_some(), | |||
| vote, | |||
| })), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Amendment not found"}))), | |||
| Err(DatabaseError(DatabaseErrorKind::ForeignKeyViolation, _)) => Ok(HttpResponse::NotFound().json( | |||
| json!({"error": "Amendment or user not found"}) | |||
| )), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to record vote: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| async fn create_and_store_amendment(request: AddAmendmentRequest) -> Result<String, AmendmentError> { | |||
| // TODO: Implement actual logic to create and store an amendment | |||
| Ok("placeholder".to_string()) | |||
| async fn create_and_store_amendment(request: AddAmendmentRequest) -> Result<SelectableAmendment, AmendmentError> { | |||
| match amendment::add_amendment(request.proposal_id, request.name, request.creator, request.paragraphs) { | |||
| Ok(amendment) => Ok(amendment), | |||
| Err(err) => Err(AmendmentError::DatabaseError(Box::from(err.to_string()))) | |||
| } | |||
| } | |||
| @@ -1,8 +1,8 @@ | |||
| use actix_web::{HttpResponse, get, post, web}; | |||
| use serde::Deserialize; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use crate::repositories::user::{NewUserInsert, create_user, find_user_by_username, get_user_for_auth_by_stellar}; | |||
| use crate::repositories::user::{create_user, find_user_by_username, get_user_for_auth_by_stellar, NewUserInsert, RegisteredUser}; | |||
| const JWT_EXPIRATION_TIME: i64 = 240; | |||
| @@ -15,8 +15,10 @@ use utoipa_gen::ToSchema; | |||
| pub struct RegisterRequest { | |||
| pub username: String, | |||
| pub password: String, | |||
| pub stellar_address: String, | |||
| pub full_name: Option<String>, | |||
| pub email: Option<String>, | |||
| /// The organization the user is joining; must already be registered | |||
| pub organization_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| @@ -24,7 +26,7 @@ pub struct RegisterRequest { | |||
| path = "/api/v1/auth/register", | |||
| request_body = RegisterRequest, | |||
| responses( | |||
| (status = 200, description = "User registered successfully"), | |||
| (status = 200, description = "User registered successfully", body = RegisteredUser), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| @@ -33,9 +35,14 @@ pub struct RegisterRequest { | |||
| #[post("register")] | |||
| pub async fn register(req: web::Json<RegisterRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| if req.username.trim().is_empty() || req.password.is_empty() || req.stellar_address.trim().is_empty() { | |||
| if req.username.trim().is_empty() || req.password.is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "username and password are required" | |||
| }))); | |||
| } | |||
| if req.organization_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "username, password and stellarAddress are required" | |||
| "error": "organizationId must be a positive integer" | |||
| }))); | |||
| } | |||
| @@ -51,17 +58,23 @@ pub async fn register(req: web::Json<RegisterRequest>) -> Result<HttpResponse, a | |||
| let new_user = NewUserInsert { | |||
| username: req.username.trim().to_string(), | |||
| password_hash, | |||
| stellar_address: req.stellar_address.trim().to_string(), | |||
| full_name: req.full_name.clone(), | |||
| email: req.email.clone(), | |||
| organization_id: req.organization_id, | |||
| }; | |||
| match create_user(new_user) { | |||
| Ok(user) => Ok(HttpResponse::Created().json(json!({"user": user}))), | |||
| Ok(user) => Ok(HttpResponse::Created().json(user)), | |||
| Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, info)) => { | |||
| Ok(HttpResponse::Conflict().json(json!({ | |||
| "error": format!("Unique constraint violation: {}", info.message()) | |||
| }))) | |||
| } | |||
| Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { | |||
| Ok(HttpResponse::BadRequest().json(json!({ | |||
| "error": "Organization not found" | |||
| }))) | |||
| } | |||
| Err(e) => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to create user: {}", e) | |||
| }))), | |||
| @@ -134,6 +147,7 @@ pub async fn login(req: web::Json<LoginRequest>) -> Result<HttpResponse, actix_w | |||
| let full_name_str = user_auth.full_name.as_deref().unwrap_or(""); | |||
| let token = match generate_jwt( | |||
| user_auth.id, | |||
| user_auth.organization_id, | |||
| full_name_str, | |||
| &user_auth.username, | |||
| &user_auth.stellar_address, | |||
| @@ -180,12 +194,17 @@ pub async fn get_nonce() -> HttpResponse { | |||
| HttpResponse::Ok().json(json!({"nonce": nonce})) | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize, ToSchema)] | |||
| pub struct FreighterLoginResponse { | |||
| pub token: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/auth/login/freighter", | |||
| request_body = FreighterLoginRequest, | |||
| responses( | |||
| (status = 200, description = "User logged in successfully"), | |||
| (status = 200, description = "User logged in successfully", body = FreighterLoginResponse), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Unauthorized"), | |||
| (status = 500, description = "Internal server error") | |||
| @@ -224,7 +243,7 @@ pub async fn login_freighter(req: web::Json<FreighterLoginRequest>) -> Result<Ht | |||
| // Build JWT | |||
| let full_name_str = user.full_name.as_deref().unwrap_or(""); | |||
| let token = match generate_jwt(user.id, full_name_str, &user.username, &user.stellar_address, Some(JWT_EXPIRATION_TIME)) { | |||
| let token = match generate_jwt(user.id, user.organization_id, full_name_str, &user.username, &user.stellar_address, Some(JWT_EXPIRATION_TIME)) { | |||
| Ok(t) => t, | |||
| Err(e) => { | |||
| return Ok(HttpResponse::InternalServerError().json(json!({ | |||
| @@ -1,10 +1,150 @@ | |||
| use serde::Deserialize; | |||
| use crate::repositories::comment; | |||
| use crate::repositories::comment::{get_comment_by_id, get_comments_for_proposal}; | |||
| use crate::types::comment::{CommentError, CommentWithUser, SelectableComment}; | |||
| use crate::utils::auth::AuthenticatedUser; | |||
| use actix_web::{HttpResponse, get, post, web}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::{IntoParams, ToSchema}; | |||
| #[derive(Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ListCommentsRequest { | |||
| pub proposal_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/comments/list", | |||
| params(ListCommentsRequest), | |||
| responses( | |||
| (status = 200, description = "List of comments for a proposal", body = Vec<CommentWithUser>), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "comment" | |||
| )] | |||
| #[get("list")] | |||
| async fn list_comments(user: AuthenticatedUser, params: web::Query<ListCommentsRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| if params.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| match get_comments_for_proposal(params.proposal_id, user.organization_id) { | |||
| Ok(comments) => Ok(HttpResponse::Ok().json(comments)), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": err.to_string()}) | |||
| )) | |||
| } | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AppendLocalCommentRequest { | |||
| pub thread_id: String, | |||
| pub struct AddCommentRequest { | |||
| pub proposal_id: i32, | |||
| pub user_id: i32, | |||
| pub content: String, | |||
| pub creator: String, | |||
| /// Id of the comment being replied to, if any. Must be on the same proposal. | |||
| pub parent_id: Option<i32>, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/comment/add", | |||
| request_body = AddCommentRequest, | |||
| responses( | |||
| (status = 200, description = "The new comment", body = SelectableComment), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "comment" | |||
| )] | |||
| #[post("add")] | |||
| async fn add_comment(params: web::Json<AddCommentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = params.into_inner(); | |||
| // Basic input validation | |||
| if req.content.trim().is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Content is required"}))); | |||
| } | |||
| if req.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| if req.user_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "userId must be a positive integer"}))); | |||
| } | |||
| if req.parent_id.is_some_and(|id| id <= 0) { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "parentId must be a positive integer"}))); | |||
| } | |||
| match create_and_store_comment(req).await { | |||
| Ok(comment) => Ok(HttpResponse::Ok().json(comment)), | |||
| Err(err) => { | |||
| match err { | |||
| CommentError::SerializationError(e) => Ok(HttpResponse::BadRequest().json( | |||
| json!({"error": format!("Invalid comment payload: {}", e)}) | |||
| )), | |||
| CommentError::InvalidParent => Ok(HttpResponse::BadRequest().json( | |||
| json!({"error": err.to_string()}) | |||
| )), | |||
| _ => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to add comment: {}", err)}) | |||
| )), | |||
| } | |||
| } | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetCommentRequest { | |||
| pub comment_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/comment/get", | |||
| params(GetCommentRequest), | |||
| responses( | |||
| (status = 200, description = "Comment details", body = CommentWithUser), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Comment not found"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "comment" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_comment(user: AuthenticatedUser, request: web::Query<GetCommentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| if request.comment_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "commentId must be a positive integer"}))); | |||
| } | |||
| match get_comment_by_id(request.comment_id, user.organization_id) { | |||
| Ok(comment) => Ok(HttpResponse::Ok().json(comment)), | |||
| Err(diesel::result::Error::NotFound) => Ok(HttpResponse::NotFound().json( | |||
| json!({"error": "Comment not found"}) | |||
| )), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to get comment: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| async fn create_and_store_comment(request: AddCommentRequest) -> Result<SelectableComment, CommentError> { | |||
| if let Some(parent_id) = request.parent_id { | |||
| match comment::comment_exists_on_proposal(parent_id, request.proposal_id) { | |||
| Ok(true) => {} | |||
| Ok(false) => return Err(CommentError::InvalidParent), | |||
| Err(err) => return Err(CommentError::DatabaseError(Box::from(err.to_string()))), | |||
| } | |||
| } | |||
| match comment::create_comment(request.content, request.proposal_id, request.user_id, request.parent_id) { | |||
| Ok(comment) => Ok(comment), | |||
| Err(err) => Err(CommentError::DatabaseError(Box::from(err.to_string()))) | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod auth; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod organization; | |||
| @@ -0,0 +1,78 @@ | |||
| use crate::repositories::organization::{create_organization, get_organization_by_id}; | |||
| use crate::types::organization::Organization; | |||
| use actix_web::{HttpResponse, get, post, web}; | |||
| use diesel::result::DatabaseErrorKind; | |||
| use diesel::result::Error::{DatabaseError, NotFound}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::{IntoParams, ToSchema}; | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct RegisterOrganizationRequest { | |||
| pub name: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/organization/register", | |||
| request_body = RegisterOrganizationRequest, | |||
| responses( | |||
| (status = 201, description = "The new organization", body = Organization), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 409, description = "An organization with this name already exists"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "organization" | |||
| )] | |||
| #[post("register")] | |||
| async fn register_organization(params: web::Json<RegisterOrganizationRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let name = params.name.trim(); | |||
| if name.is_empty() { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "Name is required"}))); | |||
| } | |||
| match create_organization(name.to_string()) { | |||
| Ok(organization) => Ok(HttpResponse::Created().json(organization)), | |||
| Err(DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => Ok(HttpResponse::Conflict().json( | |||
| json!({"error": "An organization with this name already exists"}) | |||
| )), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to register organization: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| #[derive(Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetOrganizationRequest { | |||
| pub organization_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/organization/get", | |||
| params(GetOrganizationRequest), | |||
| responses( | |||
| (status = 200, description = "Organization details", body = Organization), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 404, description = "Organization not found"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "organization" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_organization(request: web::Query<GetOrganizationRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| if request.organization_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "organizationId must be a positive integer"}))); | |||
| } | |||
| match get_organization_by_id(request.organization_id) { | |||
| Ok(organization) => Ok(HttpResponse::Ok().json(organization)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Organization not found"}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to get organization: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| @@ -1,12 +1,15 @@ | |||
| use crate::repositories::proposal::{paginate_proposals, save_proposal, save_proposal_paragraphs}; | |||
| use crate::repositories::visit::record_visit; | |||
| use crate::types::proposal::{Proposal, ProposalParagraphRequest}; | |||
| use crate::types::proposal::{Proposal, ProposalParagraphRequest, ProposalVote, ProposalWithParagraphs, UserProposalVotes, VoteChoice}; | |||
| use actix_web::{get, post, web, HttpResponse, HttpRequest}; | |||
| use chrono::Utc; | |||
| use diesel::result::DatabaseErrorKind; | |||
| use diesel::result::Error::{DatabaseError, NotFound}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::{IntoParams, ToSchema}; | |||
| use crate::repositories; | |||
| use crate::utils::auth::AuthenticatedUser; | |||
| #[derive(Deserialize, IntoParams, ToSchema)] | |||
| pub struct PaginationParams { | |||
| @@ -17,16 +20,18 @@ pub struct PaginationParams { | |||
| get, | |||
| path = "/api/v1/proposals/list", | |||
| responses( | |||
| (status = 200, description = "Returns a list of proposals"), | |||
| (status = 200, description = "Returns a list of proposals in the current user's organization"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| params(PaginationParams), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("list")] | |||
| async fn list_proposals(params: web::Query<PaginationParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| match paginate_proposals(params.offset, params.limit) { | |||
| Ok(proposals) => Ok(HttpResponse::Ok().json(json!({"proposals": proposals}))), | |||
| async fn list_proposals(user: AuthenticatedUser, params: web::Query<PaginationParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| match paginate_proposals(user.organization_id, params.offset, params.limit) { | |||
| Ok(proposals) => Ok(HttpResponse::Ok().json(proposals)), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json(json!({ | |||
| "error": format!("Failed to fetch proposals: {}", err) | |||
| }))) | |||
| @@ -95,17 +100,21 @@ struct GetProposalParams { | |||
| responses( | |||
| (status = 200, description = "Proposal retrieved successfully"), | |||
| (status = 400, description = "Invalid proposalId"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Proposal not found"), | |||
| (status = 500, description = "Failed to retrieve proposal"), | |||
| ), | |||
| params(GetProposalParams), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_proposal(params: web::Query<GetProposalParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| let proposal = repositories::proposal::get_proposal(params.id); | |||
| async fn get_proposal(user: AuthenticatedUser, params: web::Query<GetProposalParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| let proposal = repositories::proposal::get_proposal(params.id, user.organization_id); | |||
| match proposal { | |||
| Ok(p) => Ok(HttpResponse::Ok().json(json!({"proposal": p}))), | |||
| Ok(p) => Ok(HttpResponse::Ok().json(p)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Proposal not found"}))), | |||
| Err(e) => | |||
| Err(actix_web::error::ErrorInternalServerError(format!("Failed to get proposal: {}", e.to_string()))) | |||
| } | |||
| @@ -125,12 +134,15 @@ pub struct AddProposalRequest { | |||
| responses( | |||
| (status = 200, description = "Proposal added successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 500, description = "Failed to add proposal"), | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[post("add")] | |||
| async fn add_proposal( | |||
| user: AuthenticatedUser, | |||
| request: web::Json<AddProposalRequest>, | |||
| ) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = request.into_inner(); | |||
| @@ -151,13 +163,122 @@ async fn add_proposal( | |||
| creator: req.creator.to_string(), | |||
| created_at: Utc::now().naive_utc(), | |||
| updated_at: Utc::now().naive_utc(), | |||
| organization_id: user.organization_id, | |||
| }; | |||
| let proposal_id = save_proposal( | |||
| proposal_data.clone().name, | |||
| proposal_data.clone().creator | |||
| proposal_data.clone().creator, | |||
| proposal_data.organization_id, | |||
| ).expect("Could not save proposal"); | |||
| save_proposal_paragraphs(proposal_id, req.paragraphs); | |||
| Ok(HttpResponse::Ok().json(proposal_data)) | |||
| } | |||
| } | |||
| #[derive(Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetProposalContentRequest { | |||
| pub prop_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/proposal/content", | |||
| responses( | |||
| (status = 200, description = "Proposal with paragraphs", body = ProposalWithParagraphs), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Proposal not found"), | |||
| ), | |||
| params(GetProposalContentRequest), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("/content")] | |||
| pub async fn get_proposal_content(user: AuthenticatedUser, req: web::Query<GetProposalContentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| match repositories::proposal::get_proposal_content(req.prop_id, user.organization_id) { | |||
| Ok(r) => Ok(HttpResponse::Ok().json(r)), | |||
| Err(e) => | |||
| Err(actix_web::error::ErrorNotFound(format!("Failed to get proposal content: {}", e.to_string()))), | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct VoteProposalRequest { | |||
| pub proposal_id: i32, | |||
| pub choice: VoteChoice, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/proposal/vote", | |||
| request_body = VoteProposalRequest, | |||
| responses( | |||
| (status = 200, description = "The recorded vote", body = ProposalVote), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Proposal not found in the current user's organization"), | |||
| (status = 409, description = "User has already voted on this proposal"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[post("vote")] | |||
| async fn vote_proposal(user: AuthenticatedUser, params: web::Json<VoteProposalRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = params.into_inner(); | |||
| if req.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| match repositories::proposal::add_proposal_vote(req.proposal_id, user.user_id, user.organization_id, req.choice) { | |||
| Ok(vote) => Ok(HttpResponse::Ok().json(vote)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Proposal not found"}))), | |||
| Err(DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => Ok(HttpResponse::Conflict().json( | |||
| json!({"error": "User has already voted on this proposal"}) | |||
| )), | |||
| Err(DatabaseError(DatabaseErrorKind::ForeignKeyViolation, _)) => Ok(HttpResponse::NotFound().json( | |||
| json!({"error": "Proposal or user not found"}) | |||
| )), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to record vote: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| #[derive(Deserialize, IntoParams)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct GetUserVotesParams { | |||
| pub proposal_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/proposal/votes", | |||
| params(GetUserVotesParams), | |||
| responses( | |||
| (status = 200, description = "The current user's vote on the proposal and on each of its amendments", body = UserProposalVotes), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Missing, invalid, or expired token"), | |||
| (status = 404, description = "Proposal not found"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| security(("bearer_auth" = [])), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("votes")] | |||
| async fn get_user_votes(user: AuthenticatedUser, params: web::Query<GetUserVotesParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| if params.proposal_id <= 0 { | |||
| return Ok(HttpResponse::BadRequest().json(json!({"error": "proposalId must be a positive integer"}))); | |||
| } | |||
| match repositories::proposal::get_user_votes(params.proposal_id, user.user_id, user.organization_id) { | |||
| Ok(votes) => Ok(HttpResponse::Ok().json(votes)), | |||
| Err(NotFound) => Ok(HttpResponse::NotFound().json(json!({"error": "Proposal not found"}))), | |||
| Err(err) => Ok(HttpResponse::InternalServerError().json( | |||
| json!({"error": format!("Failed to get votes: {}", err)}) | |||
| )) | |||
| } | |||
| } | |||
| @@ -1,9 +1,36 @@ | |||
| // @generated automatically by Diesel CLI. | |||
| pub mod sql_types { | |||
| #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] | |||
| #[diesel(postgres_type(name = "activity_type"))] | |||
| pub struct ActivityType; | |||
| #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] | |||
| #[diesel(postgres_type(name = "amendment_status"))] | |||
| pub struct AmendmentStatus; | |||
| #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] | |||
| #[diesel(postgres_type(name = "vote_choice"))] | |||
| pub struct VoteChoice; | |||
| } | |||
| diesel::table! { | |||
| amendment_paragraphs (id) { | |||
| id -> Int4, | |||
| amendment_text -> Text, | |||
| index -> Int4, | |||
| amendment_id -> Int4, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| amendment_votes (id) { | |||
| id -> Int4, | |||
| amendment_id -> Int4, | |||
| user_id -> Int4, | |||
| in_favor -> Bool, | |||
| created_at -> Timestamptz, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| @@ -31,6 +58,16 @@ diesel::table! { | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| user_id -> Nullable<Int4>, | |||
| parent_id -> Nullable<Int4>, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| organizations (id) { | |||
| id -> Int4, | |||
| name -> Text, | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| } | |||
| } | |||
| @@ -43,6 +80,19 @@ diesel::table! { | |||
| } | |||
| } | |||
| diesel::table! { | |||
| use diesel::sql_types::*; | |||
| use super::sql_types::VoteChoice; | |||
| proposal_votes (id) { | |||
| id -> Int4, | |||
| proposal_id -> Int4, | |||
| user_id -> Int4, | |||
| choice -> VoteChoice, | |||
| created_at -> Timestamptz, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| proposals (id) { | |||
| id -> Int4, | |||
| @@ -50,6 +100,25 @@ diesel::table! { | |||
| creator -> Text, | |||
| created_at -> Timestamp, | |||
| updated_at -> Nullable<Timestamp>, | |||
| organization_id -> Int4, | |||
| } | |||
| } | |||
| diesel::table! { | |||
| use diesel::sql_types::*; | |||
| use super::sql_types::ActivityType; | |||
| user_activity (id) { | |||
| id -> Int4, | |||
| user_id -> Int4, | |||
| activity_type -> ActivityType, | |||
| proposal_id -> Nullable<Int4>, | |||
| amendment_id -> Nullable<Int4>, | |||
| comment_id -> Nullable<Int4>, | |||
| target_user_id -> Nullable<Int4>, | |||
| amount -> Nullable<Int8>, | |||
| tx_hash -> Nullable<Text>, | |||
| occurred_at -> Timestamptz, | |||
| } | |||
| } | |||
| @@ -66,6 +135,7 @@ diesel::table! { | |||
| created_at -> Timestamptz, | |||
| updated_at -> Timestamptz, | |||
| full_name -> Nullable<Text>, | |||
| organization_id -> Int4, | |||
| } | |||
| } | |||
| @@ -89,17 +159,32 @@ diesel::table! { | |||
| } | |||
| } | |||
| diesel::joinable!(amendment_paragraphs -> amendments (amendment_id)); | |||
| diesel::joinable!(amendment_votes -> amendments (amendment_id)); | |||
| diesel::joinable!(amendment_votes -> users (user_id)); | |||
| diesel::joinable!(amendments -> proposals (proposal_id)); | |||
| diesel::joinable!(comments -> proposals (proposal_id)); | |||
| diesel::joinable!(comments -> users (user_id)); | |||
| diesel::joinable!(proposal_paragraphs -> proposals (proposal_id)); | |||
| diesel::joinable!(proposal_votes -> proposals (proposal_id)); | |||
| diesel::joinable!(proposal_votes -> users (user_id)); | |||
| diesel::joinable!(proposals -> organizations (organization_id)); | |||
| diesel::joinable!(user_activity -> amendments (amendment_id)); | |||
| diesel::joinable!(user_activity -> comments (comment_id)); | |||
| diesel::joinable!(user_activity -> proposals (proposal_id)); | |||
| diesel::joinable!(users -> organizations (organization_id)); | |||
| diesel::joinable!(visits -> users (user_id)); | |||
| diesel::allow_tables_to_appear_in_same_query!( | |||
| amendment_paragraphs, | |||
| amendment_votes, | |||
| amendments, | |||
| comments, | |||
| organizations, | |||
| proposal_paragraphs, | |||
| proposal_votes, | |||
| proposals, | |||
| user_activity, | |||
| users, | |||
| visits, | |||
| ); | |||
| @@ -1,8 +1,9 @@ | |||
| use core::fmt; | |||
| use crate::schema::amendments; | |||
| use crate::schema::{amendment_paragraphs, amendment_votes, amendments}; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Debug)] | |||
| pub enum AmendmentError { | |||
| @@ -21,18 +22,7 @@ impl fmt::Display for AmendmentError { | |||
| } | |||
| } | |||
| #[derive(Queryable, Clone, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AmendmentFile { | |||
| pub name: String, | |||
| pub content: String, | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| } | |||
| #[derive(Debug, Clone, Serialize, Deserialize, diesel_derive_enum::DbEnum)] | |||
| #[derive(Debug, Clone, Serialize, Deserialize, diesel_derive_enum::DbEnum, ToSchema)] | |||
| #[db_enum(existing_type_path = "crate::schema::sql_types::AmendmentStatus")] | |||
| pub enum AmendmentStatus { | |||
| Proposed, | |||
| @@ -41,7 +31,7 @@ pub enum AmendmentStatus { | |||
| Rejected, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize)] | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendments)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| @@ -56,7 +46,7 @@ pub struct SelectableAmendment { | |||
| pub proposal_id: i32, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize)] | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendments)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| @@ -90,6 +80,75 @@ impl Amendment { | |||
| } | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| pub struct AmendmentWithSummary { | |||
| pub id: i32, | |||
| pub name: String, | |||
| pub status: AmendmentStatus, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| pub summary: Option<String>, | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| pub struct AmendmentWithParagraphs { | |||
| pub id: i32, | |||
| pub name: String, | |||
| pub status: AmendmentStatus, | |||
| pub creator: String, | |||
| pub is_current: bool, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub proposal_id: i32, | |||
| pub paragraphs: Vec<AmendmentParagraph>, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendment_paragraphs)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct NewAmendmentParagraph { | |||
| pub amendment_id: i32, | |||
| pub index: i32, | |||
| pub amendment_text: String, | |||
| } | |||
| #[derive(Queryable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendment_paragraphs)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AmendmentParagraph { | |||
| pub id: i32, | |||
| pub amendment_id: i32, | |||
| pub index: i32, | |||
| pub paragraph_text: String, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendment_votes)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct NewAmendmentVote { | |||
| pub amendment_id: i32, | |||
| pub user_id: i32, | |||
| pub in_favor: bool, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = amendment_votes)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AmendmentVote { | |||
| pub id: i32, | |||
| pub amendment_id: i32, | |||
| pub user_id: i32, | |||
| pub in_favor: bool, | |||
| pub created_at: NaiveDateTime, | |||
| } | |||
| impl From<serde_json::Error> for AmendmentError { | |||
| fn from(e: serde_json::Error) -> Self { | |||
| AmendmentError::SerializationError(e) | |||
| @@ -0,0 +1,16 @@ | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Debug, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct Claims { | |||
| // Standard-like claims | |||
| pub exp: i64, | |||
| pub iat: i64, | |||
| // Custom claims | |||
| pub user_id: i32, | |||
| pub organization_id: i32, | |||
| pub full_name: String, | |||
| pub username: String, | |||
| pub stellar_address: String, | |||
| } | |||
| @@ -1,19 +1,24 @@ | |||
| use chrono::NaiveDateTime; | |||
| use serde::{Deserialize, Serialize}; | |||
| use std::fmt; | |||
| use diesel::Insertable; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use utoipa_gen::ToSchema; | |||
| use crate::schema::comments; | |||
| #[derive(Debug)] | |||
| pub enum CommentError { | |||
| DatabaseError(Box<dyn std::error::Error>), | |||
| IpfsError(Box<dyn std::error::Error>), | |||
| SerializationError(serde_json::Error), | |||
| InvalidParent, | |||
| } | |||
| impl fmt::Display for CommentError { | |||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |||
| match self { | |||
| CommentError::DatabaseError(e) => write!(f, "Database error: {}", e), | |||
| CommentError::InvalidParent => write!(f, "Parent comment does not exist on this proposal"), | |||
| CommentError::IpfsError(e) => write!(f, "IPFS error: {}", e), | |||
| CommentError::SerializationError(e) => write!(f, "Serialization error: {}", e), | |||
| } | |||
| @@ -47,14 +52,46 @@ pub struct NewComment { | |||
| pub content: String, | |||
| pub proposal_id: i32, | |||
| pub user_id: i32, | |||
| pub parent_id: Option<i32>, | |||
| } | |||
| impl NewComment { | |||
| pub fn new(content: String, proposal_id: i32, user_id: i32) -> Self { | |||
| pub fn new(content: String, proposal_id: i32, user_id: i32, parent_id: Option<i32>) -> Self { | |||
| NewComment { | |||
| content, | |||
| proposal_id, | |||
| user_id, | |||
| parent_id, | |||
| } | |||
| } | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = comments)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct SelectableComment { | |||
| pub id: i32, | |||
| pub content: Option<String>, | |||
| pub is_current: bool, | |||
| pub proposal_id: i32, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub user_id: Option<i32>, | |||
| pub parent_id: Option<i32>, | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct CommentWithUser { | |||
| pub id: i32, | |||
| pub content: Option<String>, | |||
| pub is_current: bool, | |||
| pub proposal_id: i32, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub user_id: Option<i32>, | |||
| pub parent_id: Option<i32>, | |||
| pub username: Option<String>, | |||
| pub full_name: Option<String>, | |||
| } | |||
| @@ -1,3 +0,0 @@ | |||
| // Make IpfsResult reusable by allowing a generic error type with a default of ProposalError. | |||
| pub type IpfsResult<T, E> = Result<T, E>; | |||
| @@ -1,4 +1,6 @@ | |||
| pub(crate) mod proposal; | |||
| pub(crate) mod amendment; | |||
| pub(crate) mod ipfs; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod comment; | |||
| pub(crate) mod user_activity; | |||
| pub(crate) mod organization; | |||
| pub(crate) mod auth; | |||
| @@ -0,0 +1,24 @@ | |||
| use crate::schema::organizations; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = organizations)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct NewOrganization { | |||
| pub name: String, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = organizations)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct Organization { | |||
| pub id: i32, | |||
| pub name: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| } | |||
| @@ -5,7 +5,8 @@ use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| use crate::schema::{proposal_paragraphs, proposals}; | |||
| use crate::schema::{proposal_paragraphs, proposal_votes, proposals}; | |||
| use crate::types::amendment::AmendmentVote; | |||
| #[derive(Debug)] | |||
| pub enum ProposalError { | |||
| @@ -58,6 +59,7 @@ pub struct SelectableProposal { | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: Option<NaiveDateTime>, | |||
| pub organization_id: i32, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| @@ -69,16 +71,18 @@ pub struct Proposal { | |||
| pub creator: String, | |||
| pub created_at: NaiveDateTime, | |||
| pub updated_at: NaiveDateTime, | |||
| pub organization_id: i32, | |||
| } | |||
| impl Proposal { | |||
| pub fn new(name: String, creator: String) -> Self { | |||
| pub fn new(name: String, creator: String, organization_id: i32) -> Self { | |||
| let now = chrono::Local::now().naive_local(); | |||
| Self { | |||
| name, | |||
| creator, | |||
| created_at: now, | |||
| updated_at: now, | |||
| organization_id, | |||
| } | |||
| } | |||
| } | |||
| @@ -90,11 +94,22 @@ pub struct ProposalParagraphRequest { | |||
| pub index: i32, | |||
| } | |||
| #[derive(Queryable, Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposal_paragraphs)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalParagraphInsert { | |||
| pub proposal_id: i32, | |||
| pub paragraph_text: String, | |||
| pub index: i32, | |||
| } | |||
| #[derive(Queryable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposal_paragraphs)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalParagraph { | |||
| pub id: i32, | |||
| pub proposal_id: i32, | |||
| pub paragraph_text: String, | |||
| pub index: i32, | |||
| @@ -121,3 +136,55 @@ pub struct ProposalWithParagraphs { | |||
| pub updated_at: Option<NaiveDateTime>, | |||
| pub paragraphs: Vec<ProposalParagraph>, | |||
| } | |||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, diesel_derive_enum::DbEnum, ToSchema)] | |||
| #[db_enum(existing_type_path = "crate::schema::sql_types::VoteChoice")] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub enum VoteChoice { | |||
| Yes, | |||
| No, | |||
| Abstain, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposal_votes)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct NewProposalVote { | |||
| pub proposal_id: i32, | |||
| pub user_id: i32, | |||
| pub choice: VoteChoice, | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposal_votes)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalVote { | |||
| pub id: i32, | |||
| pub proposal_id: i32, | |||
| pub user_id: i32, | |||
| pub choice: VoteChoice, | |||
| pub created_at: NaiveDateTime, | |||
| } | |||
| /// A user's vote (if any) on a single amendment of a proposal | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct UserAmendmentVote { | |||
| pub amendment_id: i32, | |||
| /// None if the user has not voted on this amendment | |||
| pub vote: Option<AmendmentVote>, | |||
| } | |||
| /// All of a user's votes on a proposal and its amendments | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct UserProposalVotes { | |||
| pub proposal_id: i32, | |||
| pub user_id: i32, | |||
| /// None if the user has not voted on the proposal | |||
| pub proposal_vote: Option<ProposalVote>, | |||
| /// One entry per amendment on the proposal, ordered by amendment id | |||
| pub amendment_votes: Vec<UserAmendmentVote>, | |||
| } | |||
| @@ -0,0 +1,136 @@ | |||
| use crate::schema::user_activity; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, diesel_derive_enum::DbEnum, ToSchema)] | |||
| #[db_enum(existing_type_path = "crate::schema::sql_types::ActivityType")] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub enum ActivityType { | |||
| // Governance | |||
| ProposalCreated, | |||
| ProposalRevised, | |||
| ProposalWithdrawn, | |||
| ProposalSponsored, | |||
| AmendmentCreated, | |||
| AmendmentRevised, | |||
| AmendmentWithdrawn, | |||
| // Voting | |||
| ProposalVoted, | |||
| AmendmentVoted, | |||
| AmendmentVoteChanged, | |||
| // Outcomes of authored work | |||
| ProposalPassed, | |||
| ProposalRejected, | |||
| AmendmentApproved, | |||
| AmendmentRejected, | |||
| // Delegation | |||
| DelegationGranted, | |||
| DelegationRevoked, | |||
| // Deliberation | |||
| CommentCreated, | |||
| CommentEdited, | |||
| CommentEndorsed, | |||
| // Presence | |||
| SessionStarted, | |||
| ProposalViewed, | |||
| AmendmentViewed, | |||
| // Membership and on-chain stake | |||
| WalletVerified, | |||
| TokensStaked, | |||
| TokensUnstaked, | |||
| TreasuryContributed, | |||
| // Stewardship | |||
| ContentFlagged, | |||
| ModerationPerformed, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = user_activity)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct NewUserActivity { | |||
| pub user_id: i32, | |||
| pub activity_type: ActivityType, | |||
| pub proposal_id: Option<i32>, | |||
| pub amendment_id: Option<i32>, | |||
| pub comment_id: Option<i32>, | |||
| pub target_user_id: Option<i32>, | |||
| pub amount: Option<i64>, | |||
| pub tx_hash: Option<String>, | |||
| } | |||
| impl NewUserActivity { | |||
| pub fn new(user_id: i32, activity_type: ActivityType) -> Self { | |||
| NewUserActivity { | |||
| user_id, | |||
| activity_type, | |||
| proposal_id: None, | |||
| amendment_id: None, | |||
| comment_id: None, | |||
| target_user_id: None, | |||
| amount: None, | |||
| tx_hash: None, | |||
| } | |||
| } | |||
| pub fn with_proposal(mut self, proposal_id: i32) -> Self { | |||
| self.proposal_id = Some(proposal_id); | |||
| self | |||
| } | |||
| pub fn with_amendment(mut self, amendment_id: i32) -> Self { | |||
| self.amendment_id = Some(amendment_id); | |||
| self | |||
| } | |||
| pub fn with_comment(mut self, comment_id: i32) -> Self { | |||
| self.comment_id = Some(comment_id); | |||
| self | |||
| } | |||
| pub fn with_target_user(mut self, target_user_id: i32) -> Self { | |||
| self.target_user_id = Some(target_user_id); | |||
| self | |||
| } | |||
| /// `amount` is in stroops. | |||
| pub fn with_transaction(mut self, tx_hash: String, amount: i64) -> Self { | |||
| self.tx_hash = Some(tx_hash); | |||
| self.amount = Some(amount); | |||
| self | |||
| } | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = user_activity)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct UserActivity { | |||
| pub id: i32, | |||
| pub user_id: i32, | |||
| pub activity_type: ActivityType, | |||
| pub proposal_id: Option<i32>, | |||
| pub amendment_id: Option<i32>, | |||
| pub comment_id: Option<i32>, | |||
| pub target_user_id: Option<i32>, | |||
| pub amount: Option<i64>, | |||
| pub tx_hash: Option<String>, | |||
| pub occurred_at: NaiveDateTime, | |||
| } | |||
| /// Count of a single activity type for a user, the raw input to participation weighting. | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ActivityCount { | |||
| pub activity_type: ActivityType, | |||
| pub count: i64, | |||
| } | |||
| @@ -1,11 +1,18 @@ | |||
| use crate::types::auth::Claims; | |||
| use actix_web::dev::Payload; | |||
| use actix_web::error::InternalError; | |||
| use actix_web::http::header; | |||
| use actix_web::{FromRequest, HttpRequest, HttpResponse}; | |||
| use base64::Engine as _; | |||
| use base64::engine::general_purpose::STANDARD as BASE64; | |||
| use ed25519_dalek::{Signature, VerifyingKey, Verifier}; | |||
| use chrono::{Duration, Utc}; | |||
| use ed25519_dalek::{Signature, Verifier, VerifyingKey}; | |||
| use futures::future::{Ready, ready}; | |||
| use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use stellar_strkey::ed25519::PublicKey as StellarPublicKey; | |||
| use serde_json::json; | |||
| use std::env; | |||
| use chrono::{Utc, Duration}; | |||
| use jsonwebtoken::{encode, Header, EncodingKey}; | |||
| use stellar_strkey::ed25519::PublicKey as StellarPublicKey; | |||
| /// Verify a Freighter-provided ed25519 signature over a message using a Stellar G... address. | |||
| /// | |||
| @@ -42,25 +49,13 @@ pub fn generate_freighter_nonce() -> String { | |||
| uuid::Uuid::new_v4().to_string() | |||
| } | |||
| #[derive(Debug, Serialize, Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| struct Claims { | |||
| // Standard-like claims | |||
| exp: i64, | |||
| iat: i64, | |||
| // Custom claims | |||
| user_id: i32, | |||
| full_name: String, | |||
| username: String, | |||
| stellar_address: String, | |||
| } | |||
| /// Generate a JWT embedding the user's id, full name, username, and stellar address. | |||
| /// Generate a JWT embedding the user's id, organization id, full name, username, and stellar address. | |||
| /// | |||
| /// The signing secret is read from the JWT_SECRET environment variable. | |||
| /// The token uses HS256 by default and expires in `expiration_minutes` (default: 60 minutes). | |||
| pub fn generate_jwt( | |||
| user_id: i32, | |||
| organization_id: i32, | |||
| full_name: &str, | |||
| username: &str, | |||
| stellar_address: &str, | |||
| @@ -75,6 +70,7 @@ pub fn generate_jwt( | |||
| exp: (now + Duration::minutes(exp_minutes)).timestamp(), | |||
| iat: now.timestamp(), | |||
| user_id, | |||
| organization_id, | |||
| full_name: full_name.to_string(), | |||
| username: username.to_string(), | |||
| stellar_address: stellar_address.to_string(), | |||
| @@ -83,3 +79,121 @@ pub fn generate_jwt( | |||
| encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())) | |||
| .map_err(|e| format!("Failed to sign JWT: {}", e)) | |||
| } | |||
| /// Verify a JWT produced by `generate_jwt`, checking its signature and expiry. | |||
| fn verify_jwt(token: &str) -> Result<Claims, String> { | |||
| let secret = env::var("JWT_SECRET") | |||
| .map_err(|_| "JWT_SECRET not set".to_string())?; | |||
| decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256)) | |||
| .map(|data| data.claims) | |||
| .map_err(|e| format!("Invalid token: {}", e)) | |||
| } | |||
| /// The user making the request, taken from a valid `Authorization: Bearer <jwt>` header. | |||
| /// | |||
| /// Add this as a handler argument to require authentication; requests without a valid | |||
| /// token are rejected with 401 before the handler runs. | |||
| #[derive(Debug, Clone)] | |||
| pub struct AuthenticatedUser { | |||
| pub user_id: i32, | |||
| /// The organization the user belongs to; they may only see and vote on its proposals | |||
| pub organization_id: i32, | |||
| pub username: String, | |||
| pub full_name: String, | |||
| pub stellar_address: String, | |||
| } | |||
| impl From<Claims> for AuthenticatedUser { | |||
| fn from(claims: Claims) -> Self { | |||
| Self { | |||
| user_id: claims.user_id, | |||
| organization_id: claims.organization_id, | |||
| username: claims.username, | |||
| full_name: claims.full_name, | |||
| stellar_address: claims.stellar_address, | |||
| } | |||
| } | |||
| } | |||
| impl FromRequest for AuthenticatedUser { | |||
| type Error = actix_web::Error; | |||
| type Future = Ready<Result<Self, Self::Error>>; | |||
| fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { | |||
| let result = req.headers() | |||
| .get(header::AUTHORIZATION) | |||
| .ok_or_else(|| "Missing Authorization header".to_string()) | |||
| .and_then(|value| value.to_str().map_err(|_| "Invalid Authorization header".to_string())) | |||
| .and_then(|value| value.strip_prefix("Bearer ").ok_or_else(|| "Authorization header must use the Bearer scheme".to_string())) | |||
| .and_then(verify_jwt) | |||
| .map(AuthenticatedUser::from) | |||
| .map_err(|msg| { | |||
| let response = HttpResponse::Unauthorized().json(json!({"error": msg})); | |||
| InternalError::from_response(msg, response).into() | |||
| }); | |||
| ready(result) | |||
| } | |||
| } | |||
| #[cfg(test)] | |||
| mod tests { | |||
| use super::*; | |||
| use actix_web::test::TestRequest; | |||
| fn set_secret() { | |||
| // SAFETY: tests only ever set JWT_SECRET to the same value | |||
| unsafe { env::set_var("JWT_SECRET", "test-secret") }; | |||
| } | |||
| async fn extract(req: TestRequest) -> Result<AuthenticatedUser, actix_web::Error> { | |||
| let (req, mut payload) = req.to_http_parts(); | |||
| AuthenticatedUser::from_request(&req, &mut payload).await | |||
| } | |||
| #[actix_web::test] | |||
| async fn accepts_valid_bearer_token() { | |||
| set_secret(); | |||
| let token = generate_jwt(7, 3, "Ada Lovelace", "ada", "GABC", None).unwrap(); | |||
| let user = extract(TestRequest::default() | |||
| .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))) | |||
| .await | |||
| .unwrap(); | |||
| assert_eq!(user.user_id, 7); | |||
| assert_eq!(user.organization_id, 3); | |||
| assert_eq!(user.username, "ada"); | |||
| } | |||
| #[actix_web::test] | |||
| async fn rejects_missing_header() { | |||
| set_secret(); | |||
| assert!(extract(TestRequest::default()).await.is_err()); | |||
| } | |||
| #[actix_web::test] | |||
| async fn rejects_expired_token() { | |||
| set_secret(); | |||
| let token = generate_jwt(7, 3, "Ada Lovelace", "ada", "GABC", Some(-10)).unwrap(); | |||
| let result = extract(TestRequest::default() | |||
| .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))) | |||
| .await; | |||
| assert!(result.is_err()); | |||
| } | |||
| #[actix_web::test] | |||
| async fn rejects_tampered_token() { | |||
| set_secret(); | |||
| let token = generate_jwt(7, 3, "Ada Lovelace", "ada", "GABC", None).unwrap(); | |||
| let result = extract(TestRequest::default() | |||
| .insert_header((header::AUTHORIZATION, format!("Bearer {token}x")))) | |||
| .await; | |||
| assert!(result.is_err()); | |||
| } | |||
| } | |||