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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"license": "ISC",
"packageManager": "pnpm@10.28.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@socket.io/redis-adapter": "^8.3.0",
"@stellar/stellar-sdk": "^15.1.0",
"cors": "^2.8.6",
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { devicesRouter } from './routes/devices.js';
import { messagesRouter } from './routes/messages.js';
import { usersRouter } from './routes/users.js';
import { treasuryRouter } from './routes/treasury.js';
import { filesRouter } from './routes/files.js';
import { pushRouter } from './routes/push.js';
import { requireAuth, type AuthRequest } from './middleware/auth.js';

const packageJson = JSON.parse(
Expand Down Expand Up @@ -51,6 +53,8 @@ app.use('/devices', devicesRouter);
app.use('/messages', messagesRouter);
app.use('/users', usersRouter);
app.use('/treasury', treasuryRouter);
app.use('/files', filesRouter);
app.use('/push', pushRouter);

app.get('/me', requireAuth, (req, res) => {
res.json({ user: (req as AuthRequest).auth });
Expand Down
69 changes: 50 additions & 19 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,29 +238,44 @@ export const treasuryProposalStatusEnum = pgEnum('treasury_proposal_status', [
'expired',
]);

export const treasuryProposals = pgTable(
'treasury_proposals',
{
id: uuid('id').primaryKey().defaultRandom(),
contractId: text('contract_id').notNull(),
proposalId: text('proposal_id').notNull(),
conversationId: uuid('conversation_id').references(() => conversations.id, {
onDelete: 'set null',
}),
status: treasuryProposalStatusEnum('status').notNull().default('active'),
approvalsCount: integer('approvals_count').notNull().default(0),
rejectionsCount: integer('rejections_count').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('treasury_proposals_contract_proposal_idx').on(table.contractId, table.proposalId),
],
);
export const treasuryProposals = pgTable('treasury_proposals', {
id: serial('id').primaryKey(),
onChainId: integer('on_chain_id').notNull(),
conversationId: uuid('conversation_id')
.notNull()
.references(() => conversations.id, { onDelete: 'cascade' }),
proposerId: uuid('proposer_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
toAddress: text('to_address').notNull(),
tokenContract: text('token_contract').notNull(),
amount: text('amount').notNull(),
status: treasuryProposalStatusEnum('status').notNull().default('active'),
approvalsCount: integer('approvals_count').notNull().default(0),
rejectionsCount: integer('rejections_count').notNull().default(0),
threshold: integer('threshold').notNull(),
expiresAt: integer('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});

export type TreasuryProposal = typeof treasuryProposals.$inferSelect;
export type NewTreasuryProposal = typeof treasuryProposals.$inferInsert;

export const pushSubscriptions = pgTable('push_subscriptions', {
id: uuid('id').primaryKey().defaultRandom(),
deviceId: uuid('device_id')
.notNull()
.references(() => userDevices.id, { onDelete: 'cascade' }),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
});

export type PushSubscription = typeof pushSubscriptions.$inferSelect;
export type NewPushSubscription = typeof pushSubscriptions.$inferInsert;

// ─── Relations ────────────────────────────────────────────────────────────────

export const usersRelations = relations(users, ({ many }) => ({
Expand Down Expand Up @@ -340,6 +355,22 @@ export const oneTimePreKeysRelations = relations(oneTimePreKeys, ({ one }) => ({
export const userDevicesRelations = relations(userDevices, ({ one, many }) => ({
user: one(users, { fields: [userDevices.userId], references: [users.id] }),
messages: many(messages),
pushSubscriptions: many(pushSubscriptions),
}));

export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
device: one(userDevices, { fields: [pushSubscriptions.deviceId], references: [userDevices.id] }),
}));

export const treasuryProposalsRelations = relations(treasuryProposals, ({ one }) => ({
conversation: one(conversations, {
fields: [treasuryProposals.conversationId],
references: [conversations.id],
}),
proposer: one(users, {
fields: [treasuryProposals.proposerId],
references: [users.id],
}),
}));

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/src/routes/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Router } from 'express';
import type { IRouter } from 'express';
import { eq, and } from 'drizzle-orm';
import { db } from '../db/index.js';
import { messages, conversationMembers } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

export const filesRouter: IRouter = Router();
filesRouter.use(requireAuth);

const s3 = new S3Client({
region: process.env['AWS_REGION'] || 'us-east-1',
});
const bucketName = process.env['AWS_BUCKET'] || 'clicked-files';

filesRouter.get('/:fileId', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
const fileId = req.params['fileId'] as string;

if (!fileId) {
res.status(400).json({ error: 'File id is required' });
return;
}

// Find the message that references this file
const message = await db.query.messages.findFirst({
where: eq(messages.id, fileId),
});

if (!message) {
res.status(404).json({ error: 'File not found' });
return;
}

// Check if the user is a member of the conversation where the file was shared
const membership = await db.query.conversationMembers.findFirst({
where: and(
eq(conversationMembers.conversationId, message.conversationId),
eq(conversationMembers.userId, userId),
),
});

if (!membership) {
res.status(403).json({ error: 'Not authorized to access this file' });
return;
}

try {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: fileId,
});
// Short-lived URL: 5 minutes
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ url: presignedUrl });
} catch {
res.status(500).json({ error: 'Failed to generate download URL' });
}
});
64 changes: 64 additions & 0 deletions apps/backend/src/routes/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Router } from 'express';
import type { IRouter } from 'express';
import { eq, and } from 'drizzle-orm';
import { db } from '../db/index.js';
import { pushSubscriptions } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';

export const pushRouter: IRouter = Router();
pushRouter.use(requireAuth);

pushRouter.post('/subscriptions', async (req: AuthRequest, res) => {
const deviceId = req.auth!.deviceId;
const { endpoint, keys } = req.body;

if (!endpoint || !keys || !keys.p256dh || !keys.auth) {
res.status(400).json({ error: 'Missing endpoint or keys' });
return;
}

try {
// Upsert subscription
await db
.insert(pushSubscriptions)
.values({
deviceId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
})
.onConflictDoUpdate({
target: [pushSubscriptions.endpoint],
set: {
deviceId,
p256dh: keys.p256dh,
auth: keys.auth,
},
});

res.status(200).json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to register subscription' });
}
});

pushRouter.delete('/subscriptions', async (req: AuthRequest, res) => {
const deviceId = req.auth!.deviceId;
const { endpoint } = req.body;

if (!endpoint) {
res.status(400).json({ error: 'Endpoint is required' });
return;
}

try {
await db
.delete(pushSubscriptions)
.where(
and(eq(pushSubscriptions.endpoint, endpoint), eq(pushSubscriptions.deviceId, deviceId)),
);
res.status(204).send();
} catch {
res.status(500).json({ error: 'Failed to delete subscription' });
}
});
33 changes: 9 additions & 24 deletions apps/backend/src/services/stellarListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,42 +173,27 @@ async function defaultPersistTreasuryEvent(event: TreasuryProposalEvent): Promis
const newStatus = statusMap[event.eventType];

const [row] = await db
.insert(treasuryProposals)
.values({
contractId: event.contractId,
proposalId: event.proposalId,
.update(treasuryProposals)
.set({
status: newStatus,
approvalsCount: event.approvalsCount ?? 0,
rejectionsCount: event.rejectionsCount ?? 0,
})
.onConflictDoUpdate({
target: [treasuryProposals.contractId, treasuryProposals.proposalId],
set: {
status: newStatus,
approvalsCount:
event.approvalsCount !== undefined
? event.approvalsCount
: sql`${treasuryProposals.approvalsCount}`,
rejectionsCount:
event.rejectionsCount !== undefined
? event.rejectionsCount
: sql`${treasuryProposals.rejectionsCount}`,
updatedAt: sql`now()`,
},
approvalsCount: event.approvalsCount !== undefined ? event.approvalsCount : undefined,
rejectionsCount: event.rejectionsCount !== undefined ? event.rejectionsCount : undefined,
updatedAt: sql`now()`,
})
.where(eq(treasuryProposals.onChainId, Number(event.proposalId)))
.returning();

if (!row) return;

const payload = {
proposalId: row.proposalId,
proposalId: row.onChainId,
status: row.status,
approvalsCount: row.approvalsCount,
rejectionsCount: row.rejectionsCount,
};

// Emit to the linked conversation room if known; fall back to a contract-scoped room.
const room = row.conversationId ?? `treasury:${row.contractId}`;
// Emit to the linked conversation room if known.
const room = row.conversationId;
getSocketServer()?.to(room).emit('treasury_proposal_updated', payload);
}

Expand Down
42 changes: 42 additions & 0 deletions contracts/contracts/group_treasury/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,48 @@ impl GroupTreasuryContract {
.expect("proposal not found")
}

/// Returns a list of all proposals
pub fn list_proposals(env: Env) -> Vec<WithdrawProposal> {
let count: u32 = env
.storage()
.instance()
.get(&DataKey::ProposalCount)
.unwrap_or(0);
let mut proposals: Vec<WithdrawProposal> = Vec::new(&env);
for id in 1..=count {
if let Some(proposal) = env
.storage()
.instance()
.get(&DataKey::Proposal(id))
{
proposals.push_back(proposal);
}
}
proposals
}

/// Returns a list of pending proposals
pub fn get_pending_proposals(env: Env) -> Vec<WithdrawProposal> {
let count: u32 = env
.storage()
.instance()
.get(&DataKey::ProposalCount)
.unwrap_or(0);
let mut proposals: Vec<WithdrawProposal> = Vec::new(&env);
for id in 1..=count {
if let Some(proposal) = env
.storage()
.instance()
.get::<_, WithdrawProposal>(&DataKey::Proposal(id))
{
if proposal.status == ProposalStatus::Active {
proposals.push_back(proposal);
}
}
}
proposals
}

/// Shared validation for voting: authenticates the voter, confirms
/// membership, loads the proposal, and ensures it is pending, not expired,
/// and not already voted on by this address. Returns the loaded proposal.
Expand Down
Loading
Loading