Просмотр исходного кода

Add support for organizations, proposal voting, and user activity tracking

master
Jared Bell 4 дней назад
Родитель
Сommit
08f11fbc14
39 измененных файлов: 1820 добавлений и 168 удалений
  1. +10
    -0
      .idea/db-forest-config.xml
  2. +0
    -0
      migrations/.diesel_lock
  3. +1
    -0
      migrations/2026-10-02-211507-0000_amendment_paragraphs/down.sql
  4. +7
    -0
      migrations/2026-10-02-211507-0000_amendment_paragraphs/up.sql
  5. +1
    -0
      migrations/2026-10-05-213708-0000_amendment_votes/down.sql
  6. +11
    -0
      migrations/2026-10-05-213708-0000_amendment_votes/up.sql
  7. +9
    -0
      migrations/2026-10-05-230000-0000_user_activity/down.sql
  8. +82
    -0
      migrations/2026-10-05-230000-0000_user_activity/up.sql
  9. +3
    -0
      migrations/2026-10-05-233000-0000_proposal_votes/down.sql
  10. +14
    -0
      migrations/2026-10-05-233000-0000_proposal_votes/up.sql
  11. +4
    -0
      migrations/2026-10-06-000000-0000_organizations/down.sql
  12. +26
    -0
      migrations/2026-10-06-000000-0000_organizations/up.sql
  13. +3
    -0
      migrations/2026-10-06-120000-0000_comment_parent/down.sql
  14. +6
    -0
      migrations/2026-10-06-120000-0000_comment_parent/up.sql
  15. +22
    -2
      src/main.rs
  16. +43
    -2
      src/openapi/api_doc.rs
  17. +178
    -8
      src/repositories/amendment.rs
  18. +89
    -15
      src/repositories/comment.rs
  19. +3
    -1
      src/repositories/mod.rs
  20. +30
    -0
      src/repositories/organization.rs
  21. +145
    -41
      src/repositories/proposal.rs
  22. +14
    -9
      src/repositories/user.rs
  23. +57
    -0
      src/repositories/user_activity.rs
  24. +97
    -21
      src/routes/amendment.rs
  25. +29
    -10
      src/routes/auth.rs
  26. +145
    -5
      src/routes/comment.rs
  27. +2
    -1
      src/routes/mod.rs
  28. +78
    -0
      src/routes/organization.rs
  29. +131
    -10
      src/routes/proposal.rs
  30. +85
    -0
      src/schema.rs
  31. +74
    -15
      src/types/amendment.rs
  32. +16
    -0
      src/types/auth.rs
  33. +39
    -2
      src/types/comment.rs
  34. +0
    -3
      src/types/ipfs.rs
  35. +4
    -2
      src/types/mod.rs
  36. +24
    -0
      src/types/organization.rs
  37. +70
    -3
      src/types/proposal.rs
  38. +136
    -0
      src/types/user_activity.rs
  39. +132
    -18
      src/utils/auth.rs

+ 10
- 0
.idea/db-forest-config.xml Просмотреть файл

@@ -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
migrations/.diesel_lock Просмотреть файл


+ 1
- 0
migrations/2026-10-02-211507-0000_amendment_paragraphs/down.sql Просмотреть файл

@@ -0,0 +1 @@
-- This file should undo anything in `up.sql`

+ 7
- 0
migrations/2026-10-02-211507-0000_amendment_paragraphs/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)
)

+ 1
- 0
migrations/2026-10-05-213708-0000_amendment_votes/down.sql Просмотреть файл

@@ -0,0 +1 @@
drop table if exists amendment_votes;

+ 11
- 0
migrations/2026-10-05-213708-0000_amendment_votes/up.sql Просмотреть файл

@@ -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)
);

+ 9
- 0
migrations/2026-10-05-230000-0000_user_activity/down.sql Просмотреть файл

@@ -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;

+ 82
- 0
migrations/2026-10-05-230000-0000_user_activity/up.sql Просмотреть файл

@@ -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;

+ 3
- 0
migrations/2026-10-05-233000-0000_proposal_votes/down.sql Просмотреть файл

@@ -0,0 +1,3 @@
drop table if exists proposal_votes;

drop type if exists vote_choice;

+ 14
- 0
migrations/2026-10-05-233000-0000_proposal_votes/up.sql Просмотреть файл

@@ -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)
);

+ 4
- 0
migrations/2026-10-06-000000-0000_organizations/down.sql Просмотреть файл

@@ -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;

+ 26
- 0
migrations/2026-10-06-000000-0000_organizations/up.sql Просмотреть файл

@@ -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);

+ 3
- 0
migrations/2026-10-06-120000-0000_comment_parent/down.sql Просмотреть файл

@@ -0,0 +1,3 @@
drop index if exists comments_parent_id_idx;

alter table comments drop column if exists parent_id;

+ 6
- 0
migrations/2026-10-06-120000-0000_comment_parent/up.sql Просмотреть файл

@@ -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);

+ 22
- 2
src/main.rs Просмотреть файл

@@ -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)


+ 43
- 2
src/openapi/api_doc.rs Просмотреть файл

@@ -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()),
);
}
}

+ 178
- 8
src/repositories/amendment.rs Просмотреть файл

@@ -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(&paragraphs)
.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)
}
}

+ 89
- 15
src/repositories/comment.rs Просмотреть файл

@@ -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)
}

+ 3
- 1
src/repositories/mod.rs Просмотреть файл

@@ -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;

+ 30
- 0
src/repositories/organization.rs Просмотреть файл

@@ -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)
}

+ 145
- 41
src/repositories/proposal.rs Просмотреть файл

@@ -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,
}
)
}

+ 14
- 9
src/repositories/user.rs Просмотреть файл

@@ -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)
}

+ 57
- 0
src/repositories/user_activity.rs Просмотреть файл

@@ -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)
}

+ 97
- 21
src/routes/amendment.rs Просмотреть файл

@@ -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())))
}
}

+ 29
- 10
src/routes/auth.rs Просмотреть файл

@@ -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!({


+ 145
- 5
src/routes/comment.rs Просмотреть файл

@@ -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())))
}
}

+ 2
- 1
src/routes/mod.rs Просмотреть файл

@@ -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;

+ 78
- 0
src/routes/organization.rs Просмотреть файл

@@ -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)})
))
}
}

+ 131
- 10
src/routes/proposal.rs Просмотреть файл

@@ -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)})
))
}
}

+ 85
- 0
src/schema.rs Просмотреть файл

@@ -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,
);

+ 74
- 15
src/types/amendment.rs Просмотреть файл

@@ -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)


+ 16
- 0
src/types/auth.rs Просмотреть файл

@@ -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,
}

+ 39
- 2
src/types/comment.rs Просмотреть файл

@@ -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>,
}

+ 0
- 3
src/types/ipfs.rs Просмотреть файл

@@ -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>;


+ 4
- 2
src/types/mod.rs Просмотреть файл

@@ -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;

+ 24
- 0
src/types/organization.rs Просмотреть файл

@@ -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,
}

+ 70
- 3
src/types/proposal.rs Просмотреть файл

@@ -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>,
}

+ 136
- 0
src/types/user_activity.rs Просмотреть файл

@@ -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,
}

+ 132
- 18
src/utils/auth.rs Просмотреть файл

@@ -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());
}
}

Загрузка…
Отмена
Сохранить