64 Commits

Author SHA1 Message Date
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
15687a0c32 Standalone auto complete 2025-10-12 16:59:26 +08:00
37ea882ef7 Full featured auto complete 2025-10-12 16:55:32 +08:00
e624c2bb3e ⬆️ Upgrade aspire 2025-10-12 16:06:39 +08:00
9631cd3edd Auto completion in chat 2025-10-12 16:00:32 +08:00
f4a659fce5 🐛 Fix DM room member loading issue 2025-10-12 15:46:45 +08:00
1ded811b36 Publisher heatmap 2025-10-12 15:32:49 +08:00
32977d9580 🐛 Fix post controller does not contains publisher in success created response 2025-10-11 23:55:00 +08:00
aaf29e7228 🐛 Fix gateway user ip detection 2025-10-09 22:50:26 +08:00
658ef3bddf 🐛 Fix gateway IP detection issue 2025-10-09 00:10:32 +08:00
fc0bc936ce New version of sticker rendering support 2025-10-08 21:28:48 +08:00
3850ae6a8e 🔊 Rate limiting logs 2025-10-08 18:07:19 +08:00
21c99567b4 🐛 Fix wrong method to configure rate limiting 2025-10-08 18:05:59 +08:00
1315c7f4d4 🐛 Fix rate limiter 2025-10-08 18:01:25 +08:00
630a532d98 🐛 Fix app host 2025-10-08 18:01:21 +08:00
b9bb180113 Username color 2025-10-08 13:11:30 +08:00
04d74d0d70 Trying to optimize the scheduled jobs 2025-10-08 12:59:54 +08:00
6a8a0ed491 👔 Limit custom reactions 2025-10-08 02:46:56 +08:00
0f835845bf ♻️ Merge the ServiceDefault and Shared project 2025-10-07 19:44:52 +08:00
c5d8a8d07f 🔇 Mute ungraceful closed websocket 2025-10-07 17:54:58 +08:00
95e2ba1136 🐛 Fixes some issues in drive service 2025-10-07 01:07:24 +08:00
1176fde8b4 🐛 Fix health check 2025-10-07 00:41:26 +08:00
e634968e00 🐛 Brings health check back to live 2025-10-07 00:34:00 +08:00
282a1dbddc 🐛 Fix didn't expose X-Total 2025-10-06 23:40:44 +08:00
c64adace24 💄 Using remote site instead of embed frontend (removed) to handle oidc redirect 2025-10-06 13:05:50 +08:00
8ac0b28c66 🚚 Move callback to under api 2025-10-06 13:01:15 +08:00
8f71d7f9e5 🐛 Fix some bugs 2025-10-06 12:46:25 +08:00
c435e63917 Able to update the custom apps order's status 2025-10-05 22:20:32 +08:00
243159e4cc Custom apps create payment orders 2025-10-05 21:59:07 +08:00
42dad7095a 💄 Optimize the transfer 2025-10-05 16:17:57 +08:00
d1efcdede8 Transfer fee and pin validate 2025-10-05 15:52:54 +08:00
47680475b3 🐛 Fix develop service 2025-10-05 00:09:21 +08:00
6632d43f32 🐛 Trying to fix develop 2025-10-05 00:05:37 +08:00
29c4dcd71c Wallet stats 2025-10-05 00:05:31 +08:00
e7aa887715 🐛 Fix wrong signing algo 2025-10-04 19:55:27 +08:00
0f05633996 🐛 Fix oidc didn't provides with authorized party 2025-10-04 19:03:57 +08:00
966af08a33 Wallet stats 2025-10-04 15:38:58 +08:00
b25b90a074 Wallet funds 2025-10-04 01:17:21 +08:00
dcbefeaaab 👔 Purchase gift requires minimal level 2025-10-03 17:20:58 +08:00
eb83a0392a 👔 Update level requirements of purchase Stellar Program 2025-10-03 17:16:53 +08:00
85fefcf724 🐛 Fix subscription check 2025-10-03 17:16:18 +08:00
d17c26a228 👔 Skip level check when redeem gift 2025-10-03 17:12:23 +08:00
2e5ef8ff94 🐛 Fix members related operations 2025-10-03 17:07:57 +08:00
7a5f410e36 🐛 Trying to fix migration 2025-10-03 16:53:19 +08:00
0b4e8a9777 🚑 Ignoring migration error for now 2025-10-03 16:44:22 +08:00
30fd912281 Optimize queue usage 2025-10-03 16:38:10 +08:00
5bf58f0194 🐛 Fix subscription gift 2025-10-03 16:38:01 +08:00
8e3e3f09df Gateway config serving 2025-10-03 16:37:51 +08:00
fa24f14c05 Subscription gifts 2025-10-03 14:36:27 +08:00
a93b633e84 🐛 Fixes member issue 2025-10-02 17:09:11 +08:00
97a7b876db ♻️ Better file upload error 2025-10-02 01:14:03 +08:00
909fe173c2 🐛 Fix function changes not fully applied 2025-09-27 19:28:47 +08:00
58a44e8af4 Chat subscribe fixes and status update 2025-09-27 19:25:10 +08:00
1075177511 Message subscribe 2025-09-27 17:50:51 +08:00
78f8a9e638 🚚 Move packages 2025-09-27 16:30:35 +08:00
103 changed files with 15478 additions and 3499 deletions

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
root = true
[*]
indent_style = space
indent_size = 4

613
API_WALLET_FUNDS.md Normal file
View File

@@ -0,0 +1,613 @@
# Wallet Funds API Documentation
## Overview
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
## Authentication
All endpoints require Bearer token authentication:
```
Authorization: Bearer {jwt_token}
```
## Data Types
### Enums
#### FundSplitType
```typescript
enum FundSplitType {
Even = 0, // Equal distribution
Random = 1 // Lucky draw distribution
}
```
#### FundStatus
```typescript
enum FundStatus {
Created = 0, // Fund created, waiting for claims
PartiallyReceived = 1, // Some recipients claimed
FullyReceived = 2, // All recipients claimed
Expired = 3, // Fund expired, unclaimed amounts refunded
Refunded = 4 // Legacy status
}
```
### Request/Response Models
#### CreateFundRequest
```typescript
interface CreateFundRequest {
recipientAccountIds: string[]; // UUIDs of recipients
currency: string; // e.g., "points", "golds"
totalAmount: number; // Total amount to distribute
splitType: FundSplitType; // Even or Random
message?: string; // Optional message
expirationHours?: number; // Optional: hours until expiration (default: 24)
pinCode: string; // Required: 6-digit PIN code for security
}
```
#### SnWalletFund
```typescript
interface SnWalletFund {
id: string; // UUID
currency: string;
totalAmount: number;
splitType: FundSplitType;
status: FundStatus;
message?: string;
creatorAccountId: string; // UUID
creatorAccount: SnAccount; // Creator account details (includes profile)
recipients: SnWalletFundRecipient[];
expiredAt: string; // ISO 8601 timestamp
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletFundRecipient
```typescript
interface SnWalletFundRecipient {
id: string; // UUID
fundId: string; // UUID
recipientAccountId: string; // UUID
recipientAccount: SnAccount; // Recipient account details (includes profile)
amount: number; // Allocated amount
isReceived: boolean;
receivedAt?: string; // ISO 8601 timestamp (if claimed)
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletTransaction
```typescript
interface SnWalletTransaction {
id: string; // UUID
payerWalletId?: string; // UUID (null for system transfers)
payeeWalletId?: string; // UUID (null for system transfers)
currency: string;
amount: number;
remarks?: string;
type: TransactionType;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### Error Response
```typescript
interface ErrorResponse {
type: string; // Error type
title: string; // Error title
status: number; // HTTP status code
detail: string; // Error details
instance?: string; // Request instance
}
```
## API Endpoints
### 1. Create Fund
Creates a new fund (red packet) for distribution among recipients.
**Endpoint:** `POST /api/wallets/funds`
**Request Body:** `CreateFundRequest`
**Response:** `SnWalletFund` (201 Created)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"recipientAccountIds": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
],
"currency": "points",
"totalAmount": 100.00,
"splitType": "Even",
"message": "Happy New Year! 🎉",
"expirationHours": 48,
"pinCode": "123456"
}'
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440007",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Invalid PIN code
- `422 Unprocessable Entity`: Business logic violations
---
### 2. Get Funds
Retrieves funds that the authenticated user is involved in (as creator or recipient).
**Endpoint:** `GET /api/wallets/funds`
**Query Parameters:**
- `offset` (number, optional): Pagination offset (default: 0)
- `take` (number, optional): Number of items to return (default: 20, max: 100)
- `status` (FundStatus, optional): Filter by fund status
**Response:** `SnWalletFund[]` (200 OK)
**Headers:**
- `X-Total`: Total number of funds matching the criteria
**Example Request:**
```bash
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
]
```
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
---
### 3. Get Fund
Retrieves details of a specific fund.
**Endpoint:** `GET /api/wallets/funds/{id}`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletFund` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
-H "Authorization: Bearer {token}"
```
**Example Response:** (Same as create fund response)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: User doesn't have permission to view this fund
- `404 Not Found`: Fund not found
---
### 4. Receive Fund
Claims the authenticated user's portion of a fund.
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletTransaction` (200 OK)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440008",
"payerWalletId": null,
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
"currency": "points",
"amount": 33.34,
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
"type": 1,
"createdAt": "2025-10-03T22:05:00Z",
"updatedAt": "2025-10-03T22:05:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Fund expired, already claimed, not a recipient
- `401 Unauthorized`: Missing or invalid authentication
- `404 Not Found`: Fund not found
---
### 5. Get Wallet Overview
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
**Endpoint:** `GET /api/wallets/overview`
**Query Parameters:**
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
**Response:** `WalletOverview` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"startDate": "2025-01-01T00:00:00.0000000Z",
"endDate": "2025-12-31T23:59:59.0000000Z",
"summary": {
"System": {
"type": "System",
"currencies": {
"points": {
"currency": "points",
"income": 150.00,
"spending": 0.00,
"net": 150.00
}
}
},
"Transfer": {
"type": "Transfer",
"currencies": {
"points": {
"currency": "points",
"income": 25.00,
"spending": 75.00,
"net": -50.00
},
"golds": {
"currency": "golds",
"income": 0.00,
"spending": 10.00,
"net": -10.00
}
}
},
"Order": {
"type": "Order",
"currencies": {
"points": {
"currency": "points",
"income": 0.00,
"spending": 200.00,
"net": -200.00
}
}
}
},
"totalIncome": 175.00,
"totalSpending": 285.00,
"netTotal": -110.00
}
```
**Response Fields:**
- `accountId`: User's account UUID
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
- `summary`: Object keyed by transaction type
- `type`: Transaction type name
- `currencies`: Object keyed by currency code
- `currency`: Currency name
- `income`: Total money received
- `spending`: Total money spent
- `net`: Income minus spending
- `totalIncome`: Sum of all income across all types/currencies
- `totalSpending`: Sum of all spending across all types/currencies
- `netTotal`: Overall net (totalIncome - totalSpending)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
## Error Codes
### Common Error Types
#### Validation Errors
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "At least one recipient is required",
"instance": "/api/wallets/funds"
}
```
#### Insufficient Funds
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Insufficient funds",
"instance": "/api/wallets/funds"
}
```
#### Fund Not Available
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Fund is no longer available",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
#### Already Claimed
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "You have already received this fund",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
## Rate Limiting
- **Create Fund**: 10 requests per minute per user
- **Get Funds**: 60 requests per minute per user
- **Get Fund**: 60 requests per minute per user
- **Receive Fund**: 30 requests per minute per user
## Webhooks/Notifications
The system integrates with the platform's notification system:
- **Fund Created**: Creator receives confirmation
- **Fund Claimed**: Creator receives notification when someone claims
- **Fund Expired**: Creator receives refund notification
## SDK Examples
### JavaScript/TypeScript
```typescript
// Create a fund
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
const response = await fetch('/api/wallets/funds', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(fundData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Get user's funds
const getFunds = async (params?: {
offset?: number;
take?: number;
status?: FundStatus;
}): Promise<SnWalletFund[]> => {
const queryParams = new URLSearchParams();
if (params?.offset) queryParams.set('offset', params.offset.toString());
if (params?.take) queryParams.set('take', params.take.toString());
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Claim a fund
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
```
### Python
```python
import requests
from typing import List, Optional
from enum import Enum
class FundSplitType(Enum):
EVEN = 0
RANDOM = 1
class FundStatus(Enum):
CREATED = 0
PARTIALLY_RECEIVED = 1
FULLY_RECEIVED = 2
EXPIRED = 3
REFUNDED = 4
def create_fund(token: str, fund_data: dict) -> dict:
"""Create a new fund"""
response = requests.post(
'/api/wallets/funds',
json=fund_data,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
)
response.raise_for_status()
return response.json()
def get_funds(
token: str,
offset: int = 0,
take: int = 20,
status: Optional[FundStatus] = None
) -> List[dict]:
"""Get user's funds"""
params = {'offset': offset, 'take': take}
if status is not None:
params['status'] = status.value
response = requests.get(
'/api/wallets/funds',
params=params,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
def receive_fund(token: str, fund_id: str) -> dict:
"""Claim a fund portion"""
response = requests.post(
f'/api/wallets/funds/{fund_id}/receive',
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
```
## Changelog
### Version 1.0.0
- Initial release with basic red packet functionality
- Support for even and random split types
- 24-hour expiration with automatic refunds
- RESTful API endpoints
- Comprehensive error handling
## Support
For API support or questions:
- Check the main documentation at `README_WALLET_FUNDS.md`
- Review error messages for specific guidance
- Contact the development team for technical issues

View File

@@ -4,41 +4,35 @@ var builder = DistributedApplication.CreateBuilder(args);
var isDev = builder.Environment.IsDevelopment();
// Database was configured separately in each service.
// var database = builder.AddPostgres("database");
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
.WithReference(queue);
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(cache)
.WithReference(queue)
.WithReference(ringService);
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService);
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService)
.WithReference(driveService);
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(cache)
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService);
passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services =
[ringService, passService, driveService, sphereService, developService];
for (var idx = 0; idx < services.Count; idx++)
{
var service = services[idx];
service.WithReference(cache).WithReference(queue);
var grpcPort = 7002 + idx;
if (isDev)
@@ -61,14 +55,12 @@ for (var idx = 0; idx < services.Count; idx++)
ringService.WithReference(passService);
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
.WithReference(ringService)
.WithReference(passService)
.WithReference(driveService)
.WithReference(sphereService)
.WithReference(developService)
.WithEnvironment("HTTP_PORTS", "5001")
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
foreach (var service in services)
gateway.WithReference(service);
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();

View File

@@ -1,30 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
</ItemGroup>
</Project>
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,7 +10,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
}
},
"http": {
@@ -22,8 +24,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
}
}
}
}
}

View File

@@ -31,7 +31,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -16,7 +16,7 @@ namespace DysonNetwork.Develop.Identity;
[Authorize]
public class BotAccountController(
BotAccountService botService,
DeveloperService developerService,
DeveloperService ds,
DevProjectService projectService,
ILogger<BotAccountController> logger,
AccountClientHelper accounts,
@@ -50,9 +50,9 @@ public class BotAccountController(
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required][MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
}
@@ -68,7 +68,7 @@ public class BotAccountController(
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; }
@@ -83,11 +83,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to list bots");
@@ -108,11 +108,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to view bot details");
@@ -137,11 +137,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a bot");
@@ -206,11 +206,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a bot");
@@ -267,11 +267,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a bot");
@@ -374,7 +374,7 @@ public class BotAccountController(
AccountId = bot.Id.ToString(),
Label = request.Label
};
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
return Ok(SnApiKey.FromProtoValue(createdKey));
}
@@ -443,10 +443,10 @@ public class BotAccountController(
Account currentUser,
Shared.Proto.PublisherMemberRole requiredRole)
{
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -457,4 +457,4 @@ public class BotAccountController(
return (developer, project, bot);
}
}
}

View File

@@ -20,7 +20,7 @@ public class BotAccountService(
.FirstOrDefaultAsync(b => b.Id == id);
}
public async Task<IEnumerable<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
public async Task<List<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
{
return await db.BotAccounts
.Where(b => b.ProjectId == projectId)
@@ -97,7 +97,7 @@ public class BotAccountService(
{
db.Update(bot);
await db.SaveChangesAsync();
try
{
// Update the bot account in the Pass service
@@ -155,9 +155,8 @@ public class BotAccountService(
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(IEnumerable<SnBotAccount> bots)
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots)
{
bots = [.. bots];
var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds);
@@ -168,6 +167,6 @@ public class BotAccountService(
.FirstOrDefault(e => e.AutomatedId == bot.Id);
}
return bots as List<SnBotAccount> ?? [];
return bots;
}
}
}

View File

@@ -10,12 +10,9 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"KnownProxies": ["127.0.0.1", "::1"],
"Swagger": {
"PublicBasePath": "/develop"
},

View File

@@ -68,7 +68,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -41,7 +41,7 @@ public class BroadcastEventHandler(
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
new ConsumerConfig("drive_file_uploaded_handler"), cancellationToken: stoppingToken);
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
@@ -75,8 +75,8 @@ public class BroadcastEventHandler(
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload?.FileId);
await msg.NakAsync(cancellationToken: stoppingToken);
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
}
}
}

View File

@@ -70,13 +70,11 @@ public class FileController(
}
}
return StatusCode(StatusCodes.Status503ServiceUnavailable, "File is being processed. Please try again later.");
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
}
if (!file.PoolId.HasValue)
{
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
}
var pool = await fs.GetPoolAsync(file.PoolId.Value);
if (pool is null)

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -31,15 +32,18 @@ public class FileUploadController(
[HttpPost("create")]
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
{
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
}
if (!currentUser.IsSuperuser)
{
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
if (!allowed.HasPermission)
{
return Forbid();
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
}
}
@@ -48,23 +52,19 @@ public class FileUploadController(
var pool = await fileService.GetPoolAsync(request.PoolId.Value);
if (pool is null)
{
return BadRequest("Pool not found");
return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
}
if (pool.PolicyConfig.RequirePrivilege > 0)
if (pool.PolicyConfig.RequirePrivilege is > 0)
{
if (currentUser.PerkSubscription is null)
{
return new ObjectResult("You need to have join the Stellar Program to use this pool")
{ StatusCode = 403 };
}
var privilege =
currentUser.PerkSubscription is null ? 0 :
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege)
{
return new ObjectResult(
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}")
return new ObjectResult(ApiError.Unauthorized(
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
forbidden: true))
{
StatusCode = 403
};
@@ -74,14 +74,19 @@ public class FileUploadController(
var policy = pool.PolicyConfig;
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
{
return new ObjectResult("File encryption is not allowed in this pool") { StatusCode = 403 };
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
{ StatusCode = 403 };
}
if (policy.AcceptTypes is { Count: > 0 })
{
if (string.IsNullOrEmpty(request.ContentType))
{
return BadRequest("Content type is required by the pool's policy");
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
{
{ "contentType", new[] { "Content type is required by the pool's policy" } }
}))
{ StatusCode = 400 };
}
var foundMatch = policy.AcceptTypes.Any(acceptType =>
@@ -97,15 +102,18 @@ public class FileUploadController(
if (!foundMatch)
{
return new ObjectResult($"Content type {request.ContentType} is not allowed by the pool's policy")
{ StatusCode = 403 };
return new ObjectResult(
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
true))
{ StatusCode = 403 };
}
}
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
{
return new ObjectResult(
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}")
return new ObjectResult(ApiError.Unauthorized(
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
true))
{
StatusCode = 403
};
@@ -118,8 +126,10 @@ public class FileUploadController(
);
if (!ok)
{
return new ObjectResult($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB")
{ StatusCode = 403 };
return new ObjectResult(
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
true))
{ StatusCode = 403 };
}
if (!Directory.Exists(_tempPath))
@@ -170,7 +180,7 @@ public class FileUploadController(
ChunksCount = chunksCount
});
}
public class UploadChunkRequest
{
[Required]
@@ -186,7 +196,7 @@ public class FileUploadController(
var taskPath = Path.Combine(_tempPath, taskId);
if (!Directory.Exists(taskPath))
{
return NotFound("Upload task not found.");
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
}
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
@@ -202,19 +212,20 @@ public class FileUploadController(
var taskPath = Path.Combine(_tempPath, taskId);
if (!Directory.Exists(taskPath))
{
return NotFound("Upload task not found.");
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
}
var taskJsonPath = Path.Combine(taskPath, "task.json");
if (!System.IO.File.Exists(taskJsonPath))
{
return NotFound("Upload task metadata not found.");
return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
}
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
if (task == null)
{
return BadRequest("Invalid task metadata.");
return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
{ StatusCode = 400 };
}
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
@@ -229,7 +240,9 @@ public class FileUploadController(
mergedStream.Close();
System.IO.File.Delete(mergedFilePath);
Directory.Delete(taskPath, true);
return BadRequest($"Chunk {i} is missing.");
return new ObjectResult(new ApiError
{ Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 })
{ StatusCode = 400 };
}
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
@@ -237,21 +250,24 @@ public class FileUploadController(
}
}
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
{
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
}
var fileId = await Nanoid.GenerateAsync();
var cloudFile = await fileService.ProcessNewFileAsync(
currentUser,
fileId,
task.PoolId.ToString(),
task.BundleId?.ToString(),
mergedFilePath,
task.FileName,
task.ContentType,
task.EncryptPassword,
task.ExpiredAt
);
currentUser,
fileId,
task.PoolId.ToString(),
task.BundleId?.ToString(),
mergedFilePath,
task.FileName,
task.ContentType,
task.EncryptPassword,
task.ExpiredAt
);
// Clean up
Directory.Delete(taskPath, true);
@@ -259,4 +275,4 @@ public class FileUploadController(
return Ok(cloudFile);
}
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
[HttpGet("site")]
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
}

View File

@@ -12,7 +12,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,7 @@
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.RateLimiting;
using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -17,19 +17,43 @@ builder.Services.AddCors(options =>
policy.SetIsOriginAllowed(origin => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
.AllowCredentials()
.WithExposedHeaders("X-Total");
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
options.AddPolicy("fixed", context =>
{
limiterOptions.PermitLimit = 120;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120, // 120 requests...
Window = TimeSpan.FromMinutes(1), // ...per minute per IP
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10 // allow short bursts instead of instant 503s
});
});
options.OnRejected = async (context, token) =>
{
// Log the rejected IP
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter");
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
// Respond to the client
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded. Try again later.", token);
};
});
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" };
@@ -87,7 +111,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
RouteId = $"{serviceName}-swagger",
ClusterId = serviceName,
Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" },
Transforms =
Transforms =
[
new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } },
new Dictionary<string, string> { { "PathPrefix", "/swagger" } }
@@ -99,6 +123,20 @@ var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
var clusters = serviceNames.Select(serviceName => new ClusterConfig
{
ClusterId = serviceName,
HealthCheck = new()
{
Active = new()
{
Enabled = true,
Interval = TimeSpan.FromSeconds(10),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
},
Passive = new()
{
Enabled = true
}
},
Destinations = new Dictionary<string, DestinationConfig>
{
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
@@ -106,16 +144,28 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
}).ToArray();
builder.Services
.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
builder.Services.AddControllers();
var app = builder.Build();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
};
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseCors();
app.UseRateLimiter();
app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers();
app.Run();

View File

@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
}

View File

@@ -2,8 +2,8 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
@@ -80,6 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; }
@@ -115,6 +116,7 @@ public class AccountCurrentController(
if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.Links is not null) profile.Links = request.Links;
if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
if (request.PictureId is not null)
{
@@ -931,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync();
return Ok(records);
}
}
}

View File

@@ -3,8 +3,11 @@ using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Net;
using NodaTime;
using NodaTime.Extensions;
@@ -17,7 +20,8 @@ public class AccountEventService(
IStringLocalizer<Localization.AccountEventResource> localizer,
RingService.RingServiceClient pusher,
SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService
Pass.Leveling.ExperienceService experienceService,
INatsConnection nats
)
{
private static readonly Random Random = new();
@@ -37,6 +41,19 @@ public class AccountEventService(
cache.RemoveAsync(cacheKey);
}
private async Task BroadcastStatusUpdate(SnAccountStatus status)
{
await nats.PublishAsync(
AccountStatusUpdatedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
{
AccountId = status.AccountId,
Status = status,
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
}).ToByteArray()
);
}
public async Task<SnAccountStatus> GetStatus(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
@@ -158,6 +175,8 @@ public class AccountEventService(
db.AccountStatuses.Add(status);
await db.SaveChangesAsync();
await BroadcastStatusUpdate(status);
return status;
}
@@ -167,6 +186,7 @@ public class AccountEventService(
db.Update(status);
await db.SaveChangesAsync();
PurgeStatusCache(user.Id);
await BroadcastStatusUpdate(status);
}
private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative)

View File

@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context)
{

View File

@@ -43,7 +43,10 @@ public class AppDatabase(
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
public DbSet<SnAccountPunishment> Punishments { get; set; } = null!;
@@ -278,4 +281,4 @@ public static class OptionalQueryExtensions
{
return condition ? transform(source) : source;
}
}
}

View File

@@ -351,7 +351,7 @@ public class OidcProviderController(
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" },
id_token_signing_alg_values_supported = new[] { "HS256", "RS256" },
subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" },

View File

@@ -200,11 +200,13 @@ public class OidcProviderService(
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Audience = client.Slug.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
@@ -314,6 +316,7 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Azp, client.Slug),
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
@@ -516,4 +519,4 @@ public class OidcProviderService(
return false;
}
}
}

View File

@@ -16,7 +16,8 @@ public class ConnectionController(
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache
ICacheService cache,
IConfiguration configuration
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
@@ -128,7 +129,7 @@ public class ConnectionController(
}
[AllowAnonymous]
[Route("/auth/callback/{provider}")]
[Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{
@@ -142,10 +143,10 @@ public class ConnectionController(
// Get the state from the cache
var stateKey = $"{StateCachePrefix}{callbackData.State}";
// Try to get the state as OidcState first (new format)
var oidcState = await cache.GetAsync<OidcState>(stateKey);
// If not found, try to get as string (legacy format)
if (oidcState == null)
{
@@ -153,7 +154,7 @@ public class ConnectionController(
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
return BadRequest("Invalid or expired state parameter");
}
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
@@ -277,7 +278,9 @@ public class ConnectionController(
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
var siteUrl = configuration["SiteUrl"];
return Redirect(string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
@@ -309,14 +312,14 @@ public class ConnectionController(
if (connection != null)
{
// Login existing user
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
userInfo,
connection.Account,
HttpContext,
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
}
@@ -341,7 +344,10 @@ public class ConnectionController(
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/callback?token={loginToken}");
var siteUrl = configuration["SiteUrl"];
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
@@ -355,18 +361,18 @@ public class ConnectionController(
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
break;
case "POST" when request.HasFormContentType:
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
break;
}
break;
}
}
return data;
}
}
}

View File

@@ -57,7 +57,7 @@ public abstract class OidcService(
{
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
RedirectUri = Configuration["SiteUrl"] + "/auth/callback/" + ProviderName.ToLower()
};
}
@@ -292,4 +292,4 @@ public class OidcCallbackData
public string IdToken { get; set; } = "";
public string? State { get; set; }
public string? RawData { get; set; }
}
}

View File

@@ -49,7 +49,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup>

View File

@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonNetwork.Pass.csproj", "{0E8F6522-90DE-5BDE-7127-114E02C2C10F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8DAB9031-CC04-4A1A-A05A-4ADFEBAB90A8}
EndGlobalSection
EndGlobal

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddSubscriptionGift : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "wallet_gifts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
gifter_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_id = table.Column<Guid>(type: "uuid", nullable: true),
gift_code = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
subscription_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
base_price = table.Column<decimal>(type: "numeric", nullable: false),
final_price = table.Column<decimal>(type: "numeric", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
redeemed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
redeemer_id = table.Column<Guid>(type: "uuid", nullable: true),
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
is_open_gift = table.Column<bool>(type: "boolean", nullable: false),
payment_method = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
payment_details = table.Column<SnPaymentDetails>(type: "jsonb", nullable: false),
coupon_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_gifts", x => x.id);
table.ForeignKey(
name: "fk_wallet_gifts_accounts_gifter_id",
column: x => x.gifter_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_wallet_gifts_accounts_recipient_id",
column: x => x.recipient_id,
principalTable: "accounts",
principalColumn: "id");
table.ForeignKey(
name: "fk_wallet_gifts_accounts_redeemer_id",
column: x => x.redeemer_id,
principalTable: "accounts",
principalColumn: "id");
table.ForeignKey(
name: "fk_wallet_gifts_wallet_coupons_coupon_id",
column: x => x.coupon_id,
principalTable: "wallet_coupons",
principalColumn: "id");
});
migrationBuilder.AddColumn<Guid>(
name: "gift_id",
table: "wallet_subscriptions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_coupon_id",
table: "wallet_gifts",
column: "coupon_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_gift_code",
table: "wallet_gifts",
column: "gift_code");
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_gifter_id",
table: "wallet_gifts",
column: "gifter_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_recipient_id",
table: "wallet_gifts",
column: "recipient_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_redeemer_id",
table: "wallet_gifts",
column: "redeemer_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
principalTable: "wallet_gifts",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropTable(
name: "wallet_gifts");
migrationBuilder.DropIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "gift_id",
table: "wallet_subscriptions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RefactorSubscriptionRelation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "gift_id",
table: "wallet_subscriptions");
migrationBuilder.AddColumn<Guid>(
name: "subscription_id",
table: "wallet_gifts",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
principalTable: "wallet_subscriptions",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropColumn(
name: "subscription_id",
table: "wallet_gifts");
migrationBuilder.AddColumn<Guid>(
name: "gift_id",
table: "wallet_subscriptions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
principalTable: "wallet_gifts",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddWalletFund : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "wallet_funds",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
total_amount = table.Column<decimal>(type: "numeric", nullable: false),
split_type = table.Column<int>(type: "integer", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
creator_account_id = table.Column<Guid>(type: "uuid", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_funds", x => x.id);
table.ForeignKey(
name: "fk_wallet_funds_accounts_creator_account_id",
column: x => x.creator_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "wallet_fund_recipients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
fund_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_account_id = table.Column<Guid>(type: "uuid", nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false),
is_received = table.Column<bool>(type: "boolean", nullable: false),
received_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_fund_recipients", x => x.id);
table.ForeignKey(
name: "fk_wallet_fund_recipients_accounts_recipient_account_id",
column: x => x.recipient_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_wallet_fund_recipients_wallet_funds_fund_id",
column: x => x.fund_id,
principalTable: "wallet_funds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_fund_id",
table: "wallet_fund_recipients",
column: "fund_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_recipient_account_id",
table: "wallet_fund_recipients",
column: "recipient_account_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_funds_creator_account_id",
table: "wallet_funds",
column: "creator_account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "wallet_fund_recipients");
migrationBuilder.DropTable(
name: "wallet_funds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddUsernameColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<UsernameColor>(
name: "username_color",
table: "account_profiles",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "username_color",
table: "account_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,14 @@ app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.Database.MigrateAsync();
try
{
await db.Database.MigrateAsync();
}
catch (Exception err)
{
Console.WriteLine(err);
}
}
// Configure application middleware pipeline
@@ -47,4 +54,4 @@ app.ConfigureGrpcServices();
app.UseSwaggerManifest();
app.Run();
app.Run();

View File

@@ -1,11 +1,14 @@
using System.Text.Json;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Google.Protobuf;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
using NodaTime;
namespace DysonNetwork.Pass.Startup;
@@ -16,59 +19,186 @@ public class BroadcastEventHandler(
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var paymentTask = HandlePaymentEventsAsync(stoppingToken);
var webSocketTask = HandleWebSocketEventsAsync(stoppingToken);
await Task.WhenAll(paymentTask, webSocketTask);
}
private async Task HandlePaymentEventsAsync(CancellationToken stoppingToken)
{
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("payment_events", [PaymentOrderEventBase.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
new ConsumerConfig("pass_payment_handler"),
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
new ConsumerConfig("pass_payment_handler"),
cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{
PaymentOrderEvent? evt = null;
try
{
evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
logger.LogInformation(
"Received order event: {ProductIdentifier} {OrderId}",
evt?.ProductIdentifier,
evt?.OrderId
);
if (evt?.ProductIdentifier is null ||
!evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
if (evt?.ProductIdentifier is null)
continue;
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
// Handle subscription orders
if (
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleGiftOrder(order);
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else
{
// Not a subscription or gift order, skip
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.", evt?.OrderId);
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.",
evt?.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
}
}
}
private async Task HandleWebSocketEventsAsync(CancellationToken stoppingToken)
{
var connectedTask = HandleConnectedEventsAsync(stoppingToken);
var disconnectedTask = HandleDisconnectedEventsAsync(stoppingToken);
await Task.WhenAll(connectedTask, disconnectedTask);
}
private async Task HandleConnectedEventsAsync(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>("websocket_connected", cancellationToken: stoppingToken))
{
try
{
var evt =
GrpcTypeHelper.ConvertByteStringToObject<WebSocketConnectedEvent>(ByteString.CopyFrom(msg.Data));
logger.LogInformation("Received WebSocket connected event for user {AccountId}, device {DeviceId}",
evt.AccountId, evt.DeviceId);
await using var scope = serviceProvider.CreateAsyncScope();
var accountEventService = scope.ServiceProvider.GetRequiredService<AccountEventService>();
var status = await accountEventService.GetStatus(evt.AccountId);
await nats.PublishAsync(
AccountStatusUpdatedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
{
AccountId = evt.AccountId,
Status = status,
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
}).ToByteArray()
);
logger.LogInformation("Broadcasted status update for user {AccountId}", evt.AccountId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing WebSocket connected event");
}
}
}
private async Task HandleDisconnectedEventsAsync(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>("websocket_disconnected",
cancellationToken: stoppingToken))
{
try
{
var evt =
GrpcTypeHelper.ConvertByteStringToObject<WebSocketDisconnectedEvent>(ByteString.CopyFrom(msg.Data));
logger.LogInformation(
"Received WebSocket disconnected event for user {AccountId}, device {DeviceId}, IsOffline: {IsOffline}",
evt.AccountId, evt.DeviceId, evt.IsOffline
);
await using var scope = serviceProvider.CreateAsyncScope();
var accountEventService = scope.ServiceProvider.GetRequiredService<AccountEventService>();
var status = await accountEventService.GetStatus(evt.AccountId);
await nats.PublishAsync(
AccountStatusUpdatedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
{
AccountId = evt.AccountId,
Status = status,
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
}).ToByteArray()
);
logger.LogInformation("Broadcasted status update for user {AccountId}", evt.AccountId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing WebSocket disconnected event");
}
}
}
}

View File

@@ -46,6 +46,26 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(30)
.RepeatForever())
);
var giftCleanupJob = new JobKey("GiftCleanup");
q.AddJob<GiftCleanupJob>(opts => opts.WithIdentity(giftCleanupJob));
q.AddTrigger(opts => opts
.ForJob(giftCleanupJob)
.WithIdentity("GiftCleanupTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
var fundExpirationJob = new JobKey("FundExpiration");
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity(fundExpirationJob));
q.AddTrigger(opts => opts
.ForJob(fundExpirationJob)
.WithIdentity("FundExpirationTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -0,0 +1,28 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class FundExpirationJob(
AppDatabase db,
PaymentService paymentService,
ILogger<FundExpirationJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting fund expiration job...");
try
{
await paymentService.ProcessExpiredFundsAsync();
logger.LogInformation("Successfully processed expired funds");
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing expired funds");
}
}
}

View File

@@ -0,0 +1,40 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class GiftCleanupJob(
AppDatabase db,
ILogger<GiftCleanupJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting gift cleanup job...");
var now = SystemClock.Instance.GetCurrentInstant();
// Clean up gifts that are in Created status and older than 24 hours
var cutoffTime = now.Minus(Duration.FromHours(24));
var oldCreatedGifts = await db.WalletGifts
.Where(g => g.Status == GiftStatus.Created)
.Where(g => g.CreatedAt < cutoffTime)
.ToListAsync();
if (oldCreatedGifts.Count == 0)
{
logger.LogInformation("No old created gifts to clean up");
return;
}
logger.LogInformation("Found {Count} old created gifts to clean up", oldCreatedGifts.Count);
// Remove the gifts
db.WalletGifts.RemoveRange(oldCreatedGifts);
await db.SaveChangesAsync();
logger.LogInformation("Successfully cleaned up {Count} old created gifts", oldCreatedGifts.Count);
}
}

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Pass.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -8,36 +8,87 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/orders")]
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
public class OrderController(
PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db,
CustomAppService.CustomAppServiceClient customApps
) : ControllerBase
{
public class CreateOrderRequest
{
public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public string? Remarks { get; set; }
public string? ProductIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public int DurationHours { get; set; } = 24;
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
}
[HttpPost]
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await payment.CreateOrderAsync(
default,
request.Currency,
request.Amount,
NodaTime.Duration.FromHours(request.DurationHours),
request.ClientId,
request.ProductIdentifier,
request.Remarks,
request.Meta
);
return Ok(order);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
{
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
return Ok(order);
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}
[HttpPost("{id:guid}/pay")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
// Get the wallet for the current user
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
if (wallet == null)
return BadRequest("Wallet was not found.");
// Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet);
return Ok(paidOrder);
@@ -47,9 +98,46 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return BadRequest(new { error = ex.Message });
}
}
public class UpdateOrderStatusRequest
{
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
public Shared.Models.OrderStatus Status { get; set; }
}
[HttpPatch("{id:guid}/status")]
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
if (order.AppIdentifier != request.ClientId)
{
return BadRequest("Order does not belong to this client.");
}
if (request.Status != Shared.Models.OrderStatus.Finished && request.Status != Shared.Models.OrderStatus.Cancelled)
return BadRequest("Invalid status. Available statuses are Finished, Cancelled.");
order.Status = request.Status;
await db.SaveChangesAsync();
return Ok(order);
}
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}

View File

@@ -439,12 +439,346 @@ public class PaymentService(
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
}
return await CreateTransactionAsync(
// Calculate transfer fee (5%)
decimal fee = Math.Round(amount * 0.05m, 2);
decimal finalCost = amount + fee;
// Make sure the account has sufficient balanace for both fee and the transfer
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount);
if (isNewlyCreated || payerPocket.Amount < finalCost)
throw new InvalidOperationException("Insufficient funds");
// Create main transfer transaction
var transaction = await CreateTransactionAsync(
payerWallet.Id,
payeeWallet.Id,
currency,
amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}",
Shared.Models.TransactionType.Transfer);
// Create fee transaction (to system)
await CreateTransactionAsync(
payerWallet.Id,
null,
currency,
fee,
$"Transfer fee for transaction #{transaction.Id}",
Shared.Models.TransactionType.System);
return transaction;
}
}
public async Task<SnWalletFund> CreateFundAsync(
Guid creatorAccountId,
List<Guid> recipientAccountIds,
string currency,
decimal totalAmount,
Shared.Models.FundSplitType splitType,
string? message = null,
Duration? expiration = null)
{
if (recipientAccountIds.Count == 0)
throw new ArgumentException("At least one recipient is required");
if (totalAmount <= 0)
throw new ArgumentException("Total amount must be positive");
// Validate all recipient accounts exist and have wallets
var recipientWallets = new List<SnWallet>();
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
recipientWallets.Add(wallet);
}
// Check creator has sufficient funds
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
if (creatorWallet == null)
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
if (creatorPocket.Amount < totalAmount)
throw new InvalidOperationException("Insufficient funds");
// Calculate amounts for each recipient
var recipientAmounts = splitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count),
Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count),
_ => throw new ArgumentException("Invalid split type")
};
var now = SystemClock.Instance.GetCurrentInstant();
var fund = new SnWalletFund
{
CreatorAccountId = creatorAccountId,
Currency = currency,
TotalAmount = totalAmount,
SplitType = splitType,
Message = message,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient
{
RecipientAccountId = accountId,
Amount = recipientAmounts[index]
}).ToList()
};
// Deduct from creator's wallet
await db.WalletPockets
.Where(p => p.Id == creatorPocket.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
db.WalletFunds.Add(fund);
await db.SaveChangesAsync();
// Load the fund with account data including profiles
var createdFund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == fund.Id);
return createdFund!;
}
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
{
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
var remainder = totalAmount - (baseAmount * recipientCount);
var amounts = new List<decimal>();
for (int i = 0; i < recipientCount; i++)
{
var amount = baseAmount;
if (i < remainder * 100) // Distribute remainder as 0.01 increments
amount += 0.01m;
amounts.Add(amount);
}
return amounts;
}
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
{
var random = new Random();
var amounts = new List<decimal>();
// Generate random amounts that sum to total
decimal remaining = totalAmount;
for (int i = 0; i < recipientCount - 1; i++)
{
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
var minAmount = 0.01m;
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
amounts.Add(amount);
remaining -= amount;
}
// Last recipient gets the remainder
amounts.Add(Math.Round(remaining, 2));
return amounts;
}
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
{
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.FirstOrDefaultAsync(f => f.Id == fundId);
if (fund == null)
throw new InvalidOperationException("Fund not found");
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded)
throw new InvalidOperationException("Fund is no longer available");
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (recipient == null)
throw new InvalidOperationException("You are not a recipient of this fund");
if (recipient.IsReceived)
throw new InvalidOperationException("You have already received this fund");
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
if (recipientWallet == null)
throw new InvalidOperationException("Recipient wallet not found");
// Create transaction to transfer funds to recipient
var transaction = await CreateTransactionAsync(
payerWalletId: null, // System transfer
payeeWalletId: recipientWallet.Id,
currency: fund.Currency,
amount: recipient.Amount,
remarks: $"Received fund portion from {fund.CreatorAccountId}",
type: Shared.Models.TransactionType.System,
silent: true
);
// Mark as received
recipient.IsReceived = true;
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
// Update fund status
var allReceived = fund.Recipients.All(r => r.IsReceived);
if (allReceived)
fund.Status = Shared.Models.FundStatus.FullyReceived;
else
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
await db.SaveChangesAsync();
return transaction;
}
public async Task ProcessExpiredFundsAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var expiredFunds = await db.WalletFunds
.Include(f => f.Recipients)
.Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived)
.Where(f => f.ExpiredAt < now)
.ToListAsync();
foreach (var fund in expiredFunds)
{
// Calculate unclaimed amount
var unclaimedAmount = fund.Recipients
.Where(r => !r.IsReceived)
.Sum(r => r.Amount);
if (unclaimedAmount > 0)
{
// Refund to creator
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
if (creatorWallet != null)
{
await CreateTransactionAsync(
payerWalletId: null, // System refund
payeeWalletId: creatorWallet.Id,
currency: fund.Currency,
amount: unclaimedAmount,
remarks: $"Refund for expired fund {fund.Id}",
type: Shared.Models.TransactionType.System,
silent: true
);
}
}
fund.Status = Shared.Models.FundStatus.Expired;
}
await db.SaveChangesAsync();
}
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException("Wallet not found");
var query = db.PaymentTransactions
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
if (startDate.HasValue)
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
if (endDate.HasValue)
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
var transactions = await query.ToListAsync();
var overview = new WalletOverview
{
AccountId = accountId,
StartDate = startDate?.ToString("O"),
EndDate = endDate?.ToString("O"),
Summary = new Dictionary<string, TransactionSummary>()
};
// Group transactions by type and currency
var groupedTransactions = transactions
.GroupBy(t => new { t.Type, t.Currency })
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var group in groupedTransactions)
{
var typeName = group.Key.Type.ToString();
var currency = group.Key.Currency;
if (!overview.Summary.ContainsKey(typeName))
{
overview.Summary[typeName] = new TransactionSummary
{
Type = typeName,
Currencies = new Dictionary<string, CurrencySummary>()
};
}
var currencySummary = new CurrencySummary
{
Currency = currency,
Income = 0,
Spending = 0,
Net = 0
};
foreach (var transaction in group.Value)
{
if (transaction.PayeeWalletId == wallet.Id)
{
// Money coming in
currencySummary.Income += transaction.Amount;
}
else if (transaction.PayerWalletId == wallet.Id)
{
// Money going out
currencySummary.Spending += transaction.Amount;
}
}
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
overview.Summary[typeName].Currencies[currency] = currencySummary;
}
// Calculate totals
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
return overview;
}
}
public class WalletOverview
{
public Guid AccountId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
public decimal TotalIncome { get; set; }
public decimal TotalSpending { get; set; }
public decimal NetTotal { get; set; }
}
public class TransactionSummary
{
public string Type { get; set; } = null!;
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
}
public class CurrencySummary
{
public string Currency { get; set; } = null!;
public decimal Income { get; set; }
public decimal Spending { get; set; }
public decimal Net { get; set; }
}

View File

@@ -0,0 +1,335 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/subscriptions/gifts")]
public class SubscriptionGiftController(
SubscriptionService subscriptions,
AppDatabase db
) : ControllerBase
{
/// <summary>
/// Lists gifts purchased by the current user.
/// </summary>
[HttpGet("sent")]
[Authorize]
public async Task<ActionResult<List<SnWalletGift>>> ListSentGifts(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = await subscriptions.GetGiftsByGifterAsync(currentUser.Id);
var totalCount = query.Count;
var gifts = query
.Skip(offset)
.Take(take)
.ToList();
Response.Headers["X-Total"] = totalCount.ToString();
return gifts;
}
/// <summary>
/// Lists gifts received by the current user (both direct and redeemed open gifts).
/// </summary>
[HttpGet("received")]
[Authorize]
public async Task<ActionResult<List<SnWalletGift>>> ListReceivedGifts(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gifts = await subscriptions.GetGiftsByRecipientAsync(currentUser.Id);
var totalCount = gifts.Count;
gifts = gifts
.Skip(offset)
.Take(take)
.ToList();
Response.Headers["X-Total"] = totalCount.ToString();
return gifts;
}
/// <summary>
/// Gets a specific gift by ID (only if user is the gifter or recipient).
/// </summary>
[HttpGet("{giftId}")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> GetGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.Id == giftId);
if (gift is null) return NotFound();
if (gift.GifterId != currentUser.Id && gift.RecipientId != currentUser.Id &&
!(gift.IsOpenGift && gift.RedeemerId == currentUser.Id))
return NotFound();
return gift;
}
/// <summary>
/// Checks if a gift code is valid and redeemable.
/// </summary>
[HttpGet("check/{giftCode}")]
[Authorize]
public async Task<ActionResult<GiftCheckResponse>> CheckGiftCode(string giftCode)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await subscriptions.GetGiftByCodeAsync(giftCode);
if (gift is null) return NotFound("Gift code not found.");
var canRedeem = false;
var error = "";
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
{
error = gift.Status switch
{
DysonNetwork.Shared.Models.GiftStatus.Created => "Gift has not been sent yet.",
DysonNetwork.Shared.Models.GiftStatus.Redeemed => "Gift has already been redeemed.",
DysonNetwork.Shared.Models.GiftStatus.Expired => "Gift has expired.",
DysonNetwork.Shared.Models.GiftStatus.Cancelled => "Gift has been cancelled.",
_ => "Gift is not redeemable."
};
}
else if (gift.ExpiresAt < SystemClock.Instance.GetCurrentInstant())
{
error = "Gift has expired.";
}
else if (!gift.IsOpenGift && gift.RecipientId != currentUser.Id)
{
error = "This gift is intended for someone else.";
}
else
{
// Check if user already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo != null)
{
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription =
await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
if (existingSubscription is not null)
{
error = "You already have an active subscription of this type.";
}
else
{
canRedeem = true;
}
}
}
return new GiftCheckResponse
{
GiftCode = giftCode,
SubscriptionIdentifier = gift.SubscriptionIdentifier,
CanRedeem = canRedeem,
Error = error,
Message = gift.Message
};
}
public class GiftCheckResponse
{
public string GiftCode { get; set; } = null!;
public string SubscriptionIdentifier { get; set; } = null!;
public bool CanRedeem { get; set; }
public string Error { get; set; } = null!;
public string? Message { get; set; }
}
public class PurchaseGiftRequest
{
[Required] public string SubscriptionIdentifier { get; set; } = null!;
public Guid? RecipientId { get; set; }
[Required] public string PaymentMethod { get; set; } = null!;
[Required] public SnPaymentDetails PaymentDetails { get; set; } = null!;
public string? Message { get; set; }
public string? Coupon { get; set; }
public int? GiftDurationDays { get; set; } = 30; // Gift expires in 30 days by default
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
}
const int MinimumAccountLevel = 60;
/// <summary>
/// Purchases a gift subscription.
/// </summary>
[HttpPost("purchase")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> PurchaseGift([FromBody] PurchaseGiftRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (currentUser.Profile.Level < MinimumAccountLevel)
{
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null;
if (request.GiftDurationDays.HasValue)
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);
Duration? subscriptionDuration = null;
if (request.SubscriptionDurationDays.HasValue)
subscriptionDuration = Duration.FromDays(request.SubscriptionDurationDays.Value);
try
{
var gift = await subscriptions.PurchaseGiftAsync(
currentUser,
request.RecipientId,
request.SubscriptionIdentifier,
request.PaymentMethod,
request.PaymentDetails,
request.Message,
request.Coupon,
giftDuration,
subscriptionDuration
);
return gift;
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RedeemGiftRequest
{
[Required] public string GiftCode { get; set; } = null!;
}
/// <summary>
/// Redeems a gift using its code, creating a subscription for the current user.
/// </summary>
[HttpPost("redeem")]
[Authorize]
public async Task<ActionResult<RedeemGiftResponse>> RedeemGift([FromBody] RedeemGiftRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var (gift, subscription) = await subscriptions.RedeemGiftAsync(currentUser, request.GiftCode);
return new RedeemGiftResponse
{
Gift = gift,
Subscription = subscription
};
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RedeemGiftResponse
{
public SnWalletGift Gift { get; set; } = null!;
public SnWalletSubscription Subscription { get; set; } = null!;
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
[HttpPost("{giftId}/send")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> SendGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var gift = await subscriptions.MarkGiftAsSentAsync(giftId, currentUser.Id);
return gift;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
[HttpPost("{giftId}/cancel")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> CancelGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var gift = await subscriptions.CancelGiftAsync(giftId, currentUser.Id);
return gift;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Creates an order for an unpaid gift.
/// </summary>
[HttpPost("{giftId}/order")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateGiftOrder(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await subscriptions.CreateGiftOrder(currentUser.Id, giftId);
return order;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -42,6 +42,7 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -57,36 +58,33 @@ public class SubscriptionService(
if (existingSubscription is not null)
return existingSubscription;
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
// Batch database queries for account profile and coupon to reduce round trips
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
if (isFreeTrial)
{
var prevFreeTrial = await db.WalletSubscriptions
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
.FirstOrDefaultAsync();
if (prevFreeTrial is not null)
throw new InvalidOperationException("Free trial already exists.");
}
var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null);
SnWalletCoupon? couponData = null;
if (coupon is not null)
{
var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty;
couponData = await db.WalletCoupons
.Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon))
.FirstOrDefaultAsync();
if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
}
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponTask = coupon != null
? db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: Task.FromResult((SnWalletCoupon?)null);
// Await batched queries
var profile = await accountProfileTask;
var prevFreeTrial = await prevFreeTrialTask;
var couponData = await couponTask;
// Validation checks
if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists.");
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
var now = SystemClock.Instance.GetCurrentInstant();
var subscription = new SnWalletSubscription
@@ -252,6 +250,14 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
@@ -266,6 +272,41 @@ public class SubscriptionService(
);
}
/// <summary>
/// Creates a gift order for an unpaid gift.
/// </summary>
/// <param name="accountId">The account ID of the gifter.</param>
/// <param name="giftId">The unique identifier for the gift.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the created gift order.</returns>
/// <exception cref="InvalidOperationException">Thrown when the gift is not found or not in payable status.</exception>
public async Task<SnWalletOrder> CreateGiftOrder(Guid accountId, Guid giftId)
{
var gift = await db.WalletGifts
.Where(g => g.Id == giftId && g.GifterId == accountId)
.Where(g => g.Status == DysonNetwork.Shared.Models.GiftStatus.Created)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null) throw new InvalidOperationException("No matching gift found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
gift.FinalPrice,
appIdentifier: "gift",
productIdentifier: gift.SubscriptionIdentifier,
meta: new Dictionary<string, object>()
{
["gift_id"] = gift.Id.ToString()
}
);
}
public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
@@ -285,14 +326,11 @@ public class SubscriptionService(
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
{
var now = SystemClock.Instance.GetCurrentInstant();
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
// Calculate original cycle duration and extend from the current ended date
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
}
subscription.Status = Shared.Models.SubscriptionStatus.Active;
@@ -305,6 +343,36 @@ public class SubscriptionService(
return subscription;
}
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["gift_id"] is not JsonElement giftIdJson)
throw new InvalidOperationException("Invalid order.");
var giftId = Guid.TryParse(giftIdJson.ToString(), out var parsedGiftId)
? parsedGiftId
: Guid.Empty;
if (giftId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var gift = await db.WalletGifts
.Where(g => g.Id == giftId)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null)
throw new InvalidOperationException("Invalid order.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift is not in payable status.");
// Mark gift as sent after payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(gift);
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Updates the status of expired subscriptions to reflect their current state.
/// This helps maintain accurate subscription records and is typically called periodically.
@@ -326,16 +394,19 @@ public class SubscriptionService(
if (expiredSubscriptions.Count == 0)
return 0;
// Mark as expired
foreach (var subscription in expiredSubscriptions)
{
subscription.Status = Shared.Models.SubscriptionStatus.Expired;
// Clear the cache for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}";
await cache.RemoveAsync(cacheKey);
}
await db.SaveChangesAsync();
// Batch invalidate caches for better performance
var cacheTasks = expiredSubscriptions.Select(subscription =>
cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"));
await Task.WhenAll(cacheTasks);
return expiredSubscriptions.Count;
}
@@ -379,10 +450,11 @@ public class SubscriptionService(
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{
// Create a unique cache key for this subscription
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers)));
var hashIdentifier = Convert.ToHexStringLower(hashBytes);
var identifierPart = identifiers.Length == 1
? identifiers[0]
: Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))));
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{hashIdentifier}";
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}";
// Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
@@ -443,17 +515,24 @@ public class SubscriptionService(
var missingAccountIds = new List<Guid>();
// Try to get the subscription from cache first
foreach (var accountId in accountIds)
var cacheTasks = accountIds.Select(async accountId =>
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
return (accountId, found, cachedSubscription);
});
var cacheResults = await Task.WhenAll(cacheTasks);
foreach (var (accountId, found, cachedSubscription) in cacheResults)
{
if (found && cachedSubscription != null)
result[accountId] = cachedSubscription;
else
missingAccountIds.Add(accountId);
}
if (missingAccountIds.Count <= 0) return result;
if (missingAccountIds.Count == 0) return result;
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
@@ -464,18 +543,462 @@ public class SubscriptionService(
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
subscriptions = subscriptions.Where(s => s.IsAvailable).ToList();
// Group the subscriptions by account id
foreach (var subscription in subscriptions)
// Group by account and select latest available subscription
var groupedSubscriptions = subscriptions
.Where(s => s.IsAvailable)
.GroupBy(s => s.AccountId)
.ToDictionary(g => g.Key, g => g.First());
// Update results and batch cache operations
var cacheSetTasks = new List<Task>();
foreach (var kvp in groupedSubscriptions)
{
result[subscription.AccountId] = subscription;
// Cache the result if found (with 30 minutes expiry)
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}";
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
result[kvp.Key] = kvp.Value;
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
}
await Task.WhenAll(cacheSetTasks);
return result;
}
}
/// <summary>
/// Purchases a gift subscription that can be redeemed by another user.
/// </summary>
/// <param name="gifter">The account purchasing the gift.</param>
/// <param name="recipientId">Optional specific recipient. If null, creates an open gift anyone can redeem.</param>
/// <param name="subscriptionIdentifier">The subscription type being gifted.</param>
/// <param name="paymentMethod">Payment method used by the gifter.</param>
/// <param name="paymentDetails">Payment details from the gifter.</param>
/// <param name="message">Optional personal message from the gifter.</param>
/// <param name="coupon">Optional coupon code for discount.</param>
/// <param name="giftDuration">How long the gift can be redeemed (default 30 days).</param>
/// <param name="cycleDuration">The duration of the subscription once redeemed (default 30 days).</param>
/// <returns>The created gift record.</returns>
public async Task<SnWalletGift> PurchaseGiftAsync(
SnAccount gifter,
Guid? recipientId,
string subscriptionIdentifier,
string paymentMethod,
SnPaymentDetails paymentDetails,
string? message = null,
string? coupon = null,
Duration? giftDuration = null,
Duration? cycleDuration = null)
{
// Validate subscription exists
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
// Check if recipient account exists (if specified)
SnAccount? recipient = null;
if (recipientId.HasValue)
{
recipient = await db.Accounts
.Where(a => a.Id == recipientId.Value)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
}
// Validate and get coupon if provided
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponData = coupon != null
? await db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: null;
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
// Set defaults
giftDuration ??= Duration.FromDays(30); // Gift expires in 30 days
cycleDuration ??= Duration.FromDays(30); // Subscription lasts 30 days once redeemed
var now = SystemClock.Instance.GetCurrentInstant();
// Generate unique gift code
var giftCode = await GenerateUniqueGiftCodeAsync();
// Calculate final price (with potential coupon discount)
var tempSubscription = new SnWalletSubscription
{
BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
BegunAt = now // Need for price calculation
};
var finalPrice = tempSubscription.CalculateFinalPriceAt(now);
var gift = new SnWalletGift
{
GifterId = gifter.Id,
RecipientId = recipientId,
GiftCode = giftCode,
Message = message,
SubscriptionIdentifier = subscriptionIdentifier,
BasePrice = subscriptionInfo.BasePrice,
FinalPrice = finalPrice,
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
ExpiresAt = now.Plus(giftDuration.Value),
IsOpenGift = !recipientId.HasValue,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
CouponId = couponData?.Id,
CreatedAt = now,
UpdatedAt = now
};
db.WalletGifts.Add(gift);
await db.SaveChangesAsync();
gift.Gifter = gifter;
return gift;
}
/// <summary>
/// Activates a gift using the redemption code, creating a subscription for the redeemer.
/// </summary>
/// <param name="redeemer">The account redeeming the gift.</param>
/// <param name="giftCode">The unique redemption code.</param>
/// <returns>A tuple containing the activated gift and the created subscription.</returns>
public async Task<(SnWalletGift Gift, SnWalletSubscription Subscription)> RedeemGiftAsync(
SnAccount redeemer,
string giftCode)
{
var now = SystemClock.Instance.GetCurrentInstant();
// Find and validate the gift
var gift = await db.WalletGifts
.Include(g => g.Coupon) // Include coupon for price calculation
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
if (gift is null)
throw new InvalidOperationException("Gift code not found.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift is not available for redemption.");
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you.");
// Check if redeemer already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new InvalidOperationException("Invalid gift subscription type.");
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
if (sameTypeSubscription is not null)
{
// Extend existing subscription
var subscriptionDuration = Duration.FromDays(28);
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
{
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
}
else
{
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
}
if (sameTypeSubscription.RenewalAt.HasValue)
{
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
}
// Update gift status and link
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.SubscriptionId = sameTypeSubscription.Id;
gift.UpdatedAt = now;
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Update(sameTypeSubscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
await NotifyGiftRedeemed(gift, sameTypeSubscription, redeemer);
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
}
return (gift, sameTypeSubscription);
}
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription = await GetSubscriptionAsync(redeemer.Id, subscriptionsInGroup);
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// We do not check account level requirement, since it is a gift
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(28);
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration),
Identifier = gift.SubscriptionIdentifier,
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = "gift", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
OrderId = gift.Id.ToString()
},
BasePrice = gift.BasePrice,
CouponId = gift.CouponId,
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
};
// Update the gift status
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.Subscription = subscription;
gift.UpdatedAt = now;
// Save both gift and subscription
using var createTransaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await createTransaction.CommitAsync();
}
catch
{
await createTransaction.RollbackAsync();
throw;
}
// Send notification to redeemer
await NotifyGiftRedeemed(gift, subscription, redeemer);
// Send notification to gifter if different from redeemer
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null)
{
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
}
}
return (gift, subscription);
}
/// <summary>
/// Retrieves a gift by its code (for redemption checking).
/// </summary>
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
}
/// <summary>
/// Retrieves gifts purchased by a specific account.
/// Only returns gifts that have been sent or processed (not created/unpaid ones).
/// </summary>
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
{
return await db.WalletGifts
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
public async Task<SnWalletGift> MarkGiftAsSentAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift cannot be marked as sent.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
public async Task<SnWalletGift> CancelGiftAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created && gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift cannot be cancelled.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Cancelled;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
private async Task<string> GenerateUniqueGiftCodeAsync()
{
const int maxAttempts = 10;
const int codeLength = 12;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
// Generate a random code
var code = GenerateRandomCode(codeLength);
// Check if it already exists
var existingGift = await db.WalletGifts.FirstOrDefaultAsync(g => g.GiftCode == code);
if (existingGift is null)
return code;
}
throw new InvalidOperationException("Unable to generate unique gift code.");
}
private static string GenerateRandomCode(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[Random.Shared.Next(chars.Length)];
}
return new string(result);
}
private async Task NotifyGiftRedeemed(SnWalletGift gift, SnWalletSubscription subscription, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(redeemer);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.redeemed",
Title = localizer["GiftRedeemedTitle"],
Body = localizer["GiftRedeemedBody", humanReadableName],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = redeemer.Id.ToString(),
Notification = notification
}
);
}
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(gifter);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name ?? redeemer.Id.ToString()],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString(),
["redeemer_id"] = redeemer.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = gifter.Id.ToString(),
Notification = notification
}
);
}
}

View File

@@ -1,15 +1,18 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/wallets")]
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase
{
[HttpPost]
[Authorize]
@@ -39,6 +42,72 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(wallet);
}
public class WalletStats
{
public Instant PeriodBegin { get; set; }
public Instant PeriodEnd { get; set; }
public int TotalTransactions { get; set; }
public int TotalOrders { get; set; }
public Dictionary<string, decimal> IncomeCatgories { get; set; } = null!;
public Dictionary<string, decimal> OutgoingCategories { get; set; } = null!;
public decimal TotalIncome => IncomeCatgories.Values.Sum();
public decimal TotalOutgoing => OutgoingCategories.Values.Sum();
public decimal Sum => TotalIncome - TotalOutgoing;
}
[HttpGet("stats")]
[Authorize]
public async Task<ActionResult<WalletStats>> GetWalletStats([FromQuery] int period = 30)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
var periodEnd = SystemClock.Instance.GetCurrentInstant();
var periodBegin = periodEnd.Minus(Duration.FromDays(period));
var cacheKey = $"wallet:stats:{currentUser.Id}:{period}";
var cached = await cache.GetAsync<WalletStats>(cacheKey);
if (cached != null)
{
return Ok(cached);
}
var transactions = await db.PaymentTransactions
.Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) &&
t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd)
.ToListAsync();
var orders = await db.PaymentOrders
.Where(o => o.PayeeWalletId == wallet.Id &&
o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd)
.ToListAsync();
var incomeCategories = transactions
.Where(t => t.PayeeWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var outgoingCategories = transactions
.Where(t => t.PayerWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var stats = new WalletStats
{
PeriodBegin = periodBegin,
PeriodEnd = periodEnd,
TotalTransactions = transactions.Count,
TotalOrders = orders.Count,
IncomeCatgories = incomeCategories,
OutgoingCategories = outgoingCategories
};
await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1));
return Ok(stats);
}
[HttpGet("transactions")]
[Authorize]
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
@@ -57,10 +126,16 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
var transactionCount = await query.CountAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
var transactions = await query
.Skip(offset)
.Take(take)
.Include(t => t.PayerWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ToListAsync();
return Ok(transactions);
@@ -73,18 +148,18 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentOrders.AsQueryable()
.Include(o => o.Transaction)
.Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id))
.AsQueryable();
var orderCount = await query.CountAsync();
Response.Headers["X-Total"] = orderCount.ToString();
var orders = await query
.Skip(offset)
.Take(take)
@@ -102,6 +177,15 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
[Required] public Guid AccountId { get; set; }
}
public class WalletTransferRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid PayeeAccountId { get; set; }
[Required] public string PinCode { get; set; } = null!;
}
[HttpPost("balance")]
[Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")]
@@ -128,4 +212,190 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(transaction);
}
}
[HttpPost("transfer")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
try
{
var transaction = await payment.TransferAsync(
payerAccountId: currentUser.Id,
payeeAccountId: request.PayeeAccountId,
currency: request.Currency,
amount: request.Amount
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
public class CreateFundRequest
{
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
[Required] public string Currency { get; set; } = null!;
[Required] public decimal TotalAmount { get; set; }
[Required] public FundSplitType SplitType { get; set; }
public string? Message { get; set; }
public int? ExpirationHours { get; set; } // Optional: hours until expiration
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
}
[HttpPost("funds")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
Duration? expiration = null;
if (request.ExpirationHours.HasValue)
{
expiration = Duration.FromHours(request.ExpirationHours.Value);
}
var fund = await payment.CreateFundAsync(
creatorAccountId: currentUser.Id,
recipientAccountIds: request.RecipientAccountIds,
currency: request.Currency,
totalAmount: request.TotalAmount,
splitType: request.SplitType,
message: request.Message,
expiration: expiration
);
return Ok(fund);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("funds")]
[Authorize]
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] FundStatus? status = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.Where(f => f.CreatorAccountId == currentUser.Id ||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
.AsQueryable();
if (status.HasValue)
{
query = query.Where(f => f.Status == status.Value);
}
var fundCount = await query.CountAsync();
Response.Headers["X-Total"] = fundCount.ToString();
var funds = await query
.OrderByDescending(f => f.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(funds);
}
[HttpGet("funds/{id}")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id);
if (fund == null)
return NotFound("Fund not found");
// Check if user is creator or recipient
var isCreator = fund.CreatorAccountId == currentUser.Id;
var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id);
if (!isCreator && !isRecipient)
return Forbid("You don't have permission to view this fund");
return Ok(fund);
}
[HttpPost("funds/{id}/receive")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var transaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id,
fundId: id
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("overview")]
[Authorize]
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var overview = await payment.GetWalletOverviewAsync(
accountId: currentUser.Id,
startDate: startDate,
endDate: endDate
);
return Ok(overview);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
}

View File

@@ -1,7 +1,7 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5216",
"SiteUrl": "https://id.solian.app",
"BaseUrl": "http://localhost:5001",
"SiteUrl": "http://localhost:3000",
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -15,10 +15,7 @@
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
"ValidIssuer": "solar-network"
}
}
@@ -74,10 +71,7 @@
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"KnownProxies": ["127.0.0.1", "::1"],
"Service": {
"Name": "DysonNetwork.Pass",
"Url": "https://localhost:7058"

View File

@@ -1,7 +1,10 @@
using System.Net.WebSockets;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NATS.Client.Core;
using NATS.Net;
using Swashbuckle.AspNetCore.Annotations;
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
@@ -10,7 +13,8 @@ namespace DysonNetwork.Ring.Connection;
[ApiController]
public class WebSocketController(
WebSocketService ws,
ILogger<WebSocketContext> logger
ILogger<WebSocketContext> logger,
INatsConnection nats
) : ControllerBase
{
[Route("/ws")]
@@ -64,10 +68,31 @@ public class WebSocketController(
logger.LogDebug(
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
// Broadcast WebSocket connected event
await nats.PublishAsync(
WebSocketConnectedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new WebSocketConnectedEvent
{
AccountId = accountId,
DeviceId = deviceId,
IsOffline = false
}).ToByteArray(),
cancellationToken: cts.Token
);
try
{
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
}
catch (WebSocketException ex) when (ex.Message.Contains("The remote party closed the WebSocket connection without completing the close handshake"))
{
logger.LogDebug(
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} - client closed connection without proper handshake",
currentUser.Name,
currentUser.Id,
deviceId
);
}
catch (Exception ex)
{
logger.LogError(ex,
@@ -80,6 +105,19 @@ public class WebSocketController(
finally
{
ws.Disconnect(connectionKey);
// Broadcast WebSocket disconnected event
await nats.PublishAsync(
WebSocketDisconnectedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new WebSocketDisconnectedEvent
{
AccountId = accountId,
DeviceId = deviceId,
IsOffline = !WebSocketService.GetAccountIsConnected(accountId)
}).ToByteArray(),
cancellationToken: cts.Token
);
logger.LogDebug(
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}"
);
@@ -123,4 +161,4 @@ public class WebSocketController(
}
}
}
}
}

View File

@@ -42,7 +42,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -27,7 +27,7 @@ public class RingServiceGrpc(
public override Task<Empty> PushWebSocketPacket(PushWebSocketPacketRequest request, ServerCallContext context)
{
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
WebSocketService.SendPacketToAccount(Guid.Parse(request.UserId), packet);
return Task.FromResult(new Empty());
}
@@ -36,18 +36,18 @@ public class RingServiceGrpc(
ServerCallContext context)
{
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
foreach (var accountId in request.UserIds)
WebSocketService.SendPacketToAccount(Guid.Parse(accountId), packet);
return Task.FromResult(new Empty());
}
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
ServerCallContext context)
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
ServerCallContext context)
{
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
websocket.SendPacketToDevice(request.DeviceId, packet);
return Task.FromResult(new Empty());
}
@@ -56,10 +56,10 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
ServerCallContext context)
{
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
foreach (var deviceId in request.DeviceIds)
websocket.SendPacketToDevice(deviceId, packet);
return Task.FromResult(new Empty());
}
@@ -77,19 +77,19 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
: [],
AccountId = Guid.Parse(request.UserId),
};
if (request.Notification.ActionUri is not null)
notification.Meta["action_uri"] = request.Notification.ActionUri;
if (request.Notification.IsSavable)
await pushService.SaveNotification(notification);
await queueService.EnqueuePushNotification(
notification,
Guid.Parse(request.UserId),
request.Notification.IsSavable
);
return new Empty();
}
@@ -106,21 +106,21 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
: [],
};
if (request.Notification.ActionUri is not null)
notification.Meta["action_uri"] = request.Notification.ActionUri;
var userIds = request.UserIds.Select(Guid.Parse).ToList();
if (request.Notification.IsSavable)
await pushService.SaveNotification(notification, userIds);
var tasks = userIds
.Select(userId => queueService.EnqueuePushNotification(
notification,
userId,
request.Notification.IsSavable
));
await Task.WhenAll(tasks);
return new Empty();
}

View File

@@ -2,12 +2,8 @@ using System.Text.Json;
using DysonNetwork.Ring.Email;
using DysonNetwork.Ring.Notification;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Google.Protobuf;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using NATS.Net;
namespace DysonNetwork.Ring.Services;
@@ -39,29 +35,19 @@ public class QueueBackgroundService(
private async Task RunConsumerAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Queue consumer started");
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("pusher_events", [QueueName]);
var consumer = await js.CreateOrUpdateConsumerAsync(
"pusher_events",
new ConsumerConfig(QueueGroup), // durable consumer
cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
await foreach (var msg in nats.SubscribeAsync<byte[]>(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken))
{
try
{
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
if (message is not null)
{
await ProcessMessageAsync(msg, message, stoppingToken);
await msg.AckAsync(cancellationToken: stoppingToken);
await ProcessMessageAsync(message, stoppingToken);
}
else
{
logger.LogWarning($"Invalid message format for {msg.Subject}");
await msg.AckAsync(cancellationToken: stoppingToken); // Acknowledge invalid messages to avoid redelivery
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -72,12 +58,11 @@ public class QueueBackgroundService(
catch (Exception ex)
{
logger.LogError(ex, "Error in queue consumer");
await msg.NakAsync(cancellationToken: stoppingToken);
}
}
}
private async ValueTask ProcessMessageAsync(NatsJSMsg<byte[]> rawMsg, QueueMessage message,
private async ValueTask ProcessMessageAsync(QueueMessage message,
CancellationToken cancellationToken)
{
using var scope = serviceProvider.CreateScope();
@@ -118,7 +103,7 @@ public class QueueBackgroundService(
{
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
var notification = JsonSerializer.Deserialize<Shared.Models.SnNotification>(message.Data);
if (notification == null)
{
@@ -130,4 +115,4 @@ public class QueueBackgroundService(
await pushService.DeliverPushNotification(notification, cancellationToken);
logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId);
}
}
}

View File

@@ -1,7 +1,6 @@
using System.Text.Json;
using DysonNetwork.Shared.Proto;
using NATS.Client.Core;
using NATS.Net;
namespace DysonNetwork.Ring.Services;
@@ -21,15 +20,14 @@ public class QueueService(INatsConnection nats)
})
};
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
var js = nats.CreateJetStreamContext();
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
}
public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false)
{
// Update the account ID in case it wasn't set
notification.AccountId = userId;
var message = new QueueMessage
{
Type = QueueMessageType.PushNotification,
@@ -37,8 +35,7 @@ public class QueueService(INatsConnection nats)
Data = JsonSerializer.Serialize(notification)
};
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
var js = nats.CreateJetStreamContext();
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
}
}
@@ -61,4 +58,4 @@ public class EmailMessage
public string ToAddress { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}
}

View File

@@ -9,7 +9,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Notifications": {
"Push": {
@@ -36,10 +36,7 @@
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"KnownProxies": ["127.0.0.1", "::1"],
"Service": {
"Name": "DysonNetwork.Ring",
"Url": "https://localhost:7259"

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" />
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup>
</Project>

View File

@@ -316,9 +316,9 @@ public class CacheServiceRedis : ICacheService
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
{
if (string.IsNullOrEmpty(group))
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
throw new ArgumentException("Group cannot be null or empty.", nameof(group));
var groupKey = $"{GroupKeyPrefix}{group}";
var groupKey = string.Concat(GroupKeyPrefix, group);
var members = await _database.SetMembersAsync(groupKey);
return members.Select(m => m.ToString());
@@ -396,4 +396,4 @@ public class CacheServiceRedis : ICacheService
var result = await func();
return (true, result);
}
}
}

View File

@@ -1,7 +1,7 @@
using System.Text;
using System.Globalization;
using System.Text;
namespace DysonNetwork.Shared.Content;
namespace DysonNetwork.Shared.Data;
public abstract partial class TextSanitizer
{

View File

@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Google.Api.CommonProtos" Version="2.17.0" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
<PackageReference Include="Google.Protobuf" Version="3.32.1" />
<PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
<PackageReference Include="Grpc" Version="2.46.6" />
<PackageReference Include="Grpc.AspNetCore.Server.ClientFactory" Version="2.71.0" />
@@ -33,18 +33,23 @@
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" />
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
</ItemGroup>
<ItemGroup>
<Folder Include="Error\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -118,19 +118,14 @@ public static class Extensions
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
return app;
}

View File

@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Shared.Error;
namespace DysonNetwork.Shared.Http;
/// <summary>
/// Standardized error payload to return to clients.

View File

@@ -142,6 +142,40 @@ public abstract class Leveling
}
}
public class UsernameColor
{
public string Type { get; set; } = "plain"; // "plain" | "gradient"
public string? Value { get; set; } // e.g. "red" or "#ff6600"
public string? Direction { get; set; } // e.g. "to right"
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
public Proto.UsernameColor ToProtoValue()
{
var proto = new Proto.UsernameColor
{
Type = Type,
Value = Value,
Direction = Direction,
};
if (Colors is not null)
{
proto.Colors.AddRange(Colors);
}
return proto;
}
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
{
return new UsernameColor
{
Type = proto.Type,
Value = proto.Value,
Direction = proto.Direction,
Colors = proto.Colors?.ToList()
};
}
}
public class SnAccountProfile : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -154,6 +188,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[Column(TypeName = "jsonb")] public List<ProfileLink>? Links { get; set; }
[Column(TypeName = "jsonb")] public UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; }
@@ -209,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(),
UsernameColor = UsernameColor?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
@@ -238,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId),
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};

View File

@@ -0,0 +1,19 @@
using NodaTime;
namespace DysonNetwork.Shared.Models;
public class ActivityHeatmap
{
public string Unit { get; set; } = "posts";
public Instant PeriodStart { get; set; }
public Instant PeriodEnd { get; set; }
public List<ActivityHeatmapItem> Items { get; set; } = new();
}
public class ActivityHeatmapItem
{
public Instant Date { get; set; }
public int Count { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Shared.Models;
public class AutocompletionRequest
{
[Required] public string Content { get; set; } = null!;
}
public class Autocompletion
{
public string Type { get; set; } = null!;
public string Keyword { get; set; } = null!;
public object Data { get; set; } = null!;
}

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using NodaTime;
using NodaTime.Serialization.Protobuf;

View File

@@ -34,7 +34,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
public Guid ProjectId { get; set; }
public SnDevProject Project { get; set; } = null!;
[NotMapped]
public SnDeveloper Developer => Project.Developer;
@@ -81,36 +81,41 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
};
}
public SnCustomApp FromProtoValue(Proto.CustomApp p)
public static SnCustomApp FromProtoValue(Proto.CustomApp p)
{
Id = Guid.Parse(p.Id);
Slug = p.Slug;
Name = p.Name;
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description;
Status = p.Status switch
var obj = new SnCustomApp
{
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
_ => CustomAppStatus.Developing
Id = Guid.Parse(p.Id),
Slug = p.Slug,
Name = p.Name,
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description,
Status = p.Status switch
{
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
_ => CustomAppStatus.Developing
},
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId),
CreatedAt = p.CreatedAt.ToInstant(),
UpdatedAt = p.UpdatedAt.ToInstant(),
};
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
CreatedAt = p.CreatedAt.ToInstant();
UpdatedAt = p.UpdatedAt.ToInstant();
if (p.Picture is not null) Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
if (p.Background is not null) Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification is not null) Verification = SnVerificationMark.FromProtoValue(p.Verification);
if (p.Picture is not null) obj.Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
if (p.Background is not null) obj.Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification is not null) obj.Verification = SnVerificationMark.FromProtoValue(p.Verification);
if (p.Links is not null)
{
Links = new SnCustomAppLinks
obj.Links = new SnCustomAppLinks
{
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
};
}
return this;
return obj;
}
}

View File

@@ -35,7 +35,7 @@ public class PolicyConfig
public bool AllowAnonymous { get; set; } = true;
public List<string>? AcceptTypes { get; set; }
public long? MaxFileSize { get; set; }
public int RequirePrivilege { get; set; } = 0;
public int? RequirePrivilege { get; set; } = 0;
}
public class FilePool : ModelBase, IIdentifiedResource
@@ -47,8 +47,8 @@ public class FilePool : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
[Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
public bool IsHidden { get; set; } = false;
public Guid? AccountId { get; set; }
public string ResourceIdentifier => $"file-pool/{Id}";
}
}

View File

@@ -123,7 +123,7 @@ public class SnPostCategorySubscription : ModelBase
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public Guid? CategoryId { get; set; }
public SnPostCategory? Category { get; set; }
public Guid? TagId { get; set; }
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
}
public class SnPostAward : ModelBase
@@ -176,7 +177,7 @@ public class SnPostAward : ModelBase
public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; }

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
@@ -30,21 +31,21 @@ public record class SubscriptionTypeData(
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
1200,
3
20
),
[SubscriptionType.Nova] = new SubscriptionTypeData(
SubscriptionType.Nova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
2400,
6
40
),
[SubscriptionType.Supernova] = new SubscriptionTypeData(
SubscriptionType.Supernova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
3600,
9
60
)
};
@@ -58,6 +59,186 @@ public record class SubscriptionTypeData(
};
}
/// <summary>
/// Represents a gifted subscription that can be claimed by another user.
/// Support both direct gifts (to specific users) and open gifts (anyone can redeem via link/code).
/// </summary>
[Index(nameof(GiftCode))]
[Index(nameof(GifterId))]
[Index(nameof(RecipientId))]
public class SnWalletGift : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The user who purchased/gave the gift.
/// </summary>
public Guid GifterId { get; set; }
public SnAccount Gifter { get; set; } = null!;
/// <summary>
/// The intended recipient. Null for open gifts that anyone can redeem.
/// </summary>
public Guid? RecipientId { get; set; }
public SnAccount? Recipient { get; set; }
/// <summary>
/// Unique redemption code/link identifier for the gift.
/// </summary>
[MaxLength(128)]
public string GiftCode { get; set; } = null!;
/// <summary>
/// Optional custom message from the gifter.
/// </summary>
[MaxLength(1000)]
public string? Message { get; set; }
/// <summary>
/// The subscription type being gifted.
/// </summary>
[MaxLength(4096)]
public string SubscriptionIdentifier { get; set; } = null!;
/// <summary>
/// The original price before any discounts.
/// </summary>
public decimal BasePrice { get; set; }
/// <summary>
/// The final price paid after discounts.
/// </summary>
public decimal FinalPrice { get; set; }
/// <summary>
/// Current status of the gift.
/// </summary>
public GiftStatus Status { get; set; } = GiftStatus.Created;
/// <summary>
/// When the gift was redeemed. Null if not yet redeemed.
/// </summary>
public Instant? RedeemedAt { get; set; }
/// <summary>
/// The user who redeemed the gift (if different from recipient).
/// </summary>
public Guid? RedeemerId { get; set; }
public SnAccount? Redeemer { get; set; }
/// <summary>
/// The subscription created when the gift is redeemed.
/// </summary>
[JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
public Guid? SubscriptionId { get; set; }
/// <summary>
/// When the gift expires and can no longer be redeemed.
/// </summary>
public Instant ExpiresAt { get; set; }
/// <summary>
/// Whether this gift can be redeemed by anyone (open gift) or only the specified recipient.
/// </summary>
public bool IsOpenGift { get; set; }
/// <summary>
/// Payment method used by the gifter.
/// </summary>
[MaxLength(4096)]
public string PaymentMethod { get; set; } = null!;
[Column(TypeName = "jsonb")]
public SnPaymentDetails PaymentDetails { get; set; } = null!;
/// <summary>
/// Coupon used for the gift purchase.
/// </summary>
public Guid? CouponId { get; set; }
public SnWalletCoupon? Coupon { get; set; }
/// <summary>
/// Checks if the gift can still be redeemed.
/// </summary>
[NotMapped]
public bool IsRedeemable
{
get
{
if (Status != GiftStatus.Sent) return false;
var now = SystemClock.Instance.GetCurrentInstant();
return now <= ExpiresAt;
}
}
/// <summary>
/// Checks if the gift has expired.
/// </summary>
[NotMapped]
public bool IsExpired
{
get
{
if (Status == GiftStatus.Redeemed || Status == GiftStatus.Cancelled) return false;
var now = SystemClock.Instance.GetCurrentInstant();
return now > ExpiresAt;
}
}
// TODO: Uncomment once protobuf files are regenerated
/*
public Proto.Gift ToProtoValue() => new()
{
Id = Id.ToString(),
GifterId = GifterId.ToString(),
RecipientId = RecipientId?.ToString(),
GiftCode = GiftCode,
Message = Message,
SubscriptionIdentifier = SubscriptionIdentifier,
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
Status = (Proto.GiftStatus)Status,
RedeemedAt = RedeemedAt?.ToTimestamp(),
RedeemerId = RedeemerId?.ToString(),
SubscriptionId = SubscriptionId?.ToString(),
ExpiresAt = ExpiresAt.ToTimestamp(),
IsOpenGift = IsOpenGift,
PaymentMethod = PaymentMethod,
PaymentDetails = PaymentDetails.ToProtoValue(),
CouponId = CouponId?.ToString(),
Coupon = Coupon?.ToProtoValue(),
IsRedeemable = IsRedeemable,
IsExpired = IsExpired,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
public static SnWalletGift FromProtoValue(Proto.Gift proto) => new()
{
Id = Guid.Parse(proto.Id),
GifterId = Guid.Parse(proto.GifterId),
RecipientId = proto.HasRecipientId ? Guid.Parse(proto.RecipientId) : null,
GiftCode = proto.GiftCode,
Message = proto.Message,
SubscriptionIdentifier = proto.SubscriptionIdentifier,
BasePrice = decimal.Parse(proto.BasePrice),
FinalPrice = decimal.Parse(proto.FinalPrice),
Status = (GiftStatus)proto.Status,
RedeemedAt = proto.RedeemedAt?.ToInstant(),
RedeemerId = proto.HasRedeemerId ? Guid.Parse(proto.RedeemerId) : null,
SubscriptionId = proto.HasSubscriptionId ? Guid.Parse(proto.SubscriptionId) : null,
ExpiresAt = proto.ExpiresAt.ToInstant(),
IsOpenGift = proto.IsOpenGift,
PaymentMethod = proto.PaymentMethod,
PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails),
CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null,
Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
*/
}
public abstract class SubscriptionType
{
/// <summary>
@@ -99,11 +280,24 @@ public enum SubscriptionStatus
Cancelled
}
public enum GiftStatus
{
Created = 0,
Sent = 1,
Redeemed = 2,
Expired = 3,
Cancelled = 4
}
/// <summary>
/// The subscription is for the Stellar Program in most cases.
/// The paid subscription in another word.
/// </summary>
[Index(nameof(Identifier))]
[Index(nameof(AccountId))]
[Index(nameof(Status))]
[Index(nameof(AccountId), nameof(Identifier))]
[Index(nameof(AccountId), nameof(IsActive))]
public class SnWalletSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -142,40 +336,50 @@ public class SnWalletSubscription : ModelBase
public Guid AccountId { get; set; }
public SnAccount Account { get; set; } = null!;
/// <summary>
/// If this subscription was redeemed from a gift, this references the gift record.
/// </summary>
public SnWalletGift? Gift { get; set; }
[NotMapped]
public bool IsAvailable
{
get
{
if (!IsActive) return false;
var now = SystemClock.Instance.GetCurrentInstant();
if (BegunAt > now) return false;
if (EndedAt.HasValue && now > EndedAt.Value) return false;
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Active) return false;
return true;
}
get => IsAvailableAt(SystemClock.Instance.GetCurrentInstant());
}
[NotMapped]
public decimal FinalPrice
{
get
{
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice;
get => CalculateFinalPriceAt(SystemClock.Instance.GetCurrentInstant());
}
var now = SystemClock.Instance.GetCurrentInstant();
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value ||
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice;
/// <summary>
/// Optimized method to check availability at a specific instant (avoids repeated SystemClock calls).
/// </summary>
public bool IsAvailableAt(Instant currentInstant)
{
if (!IsActive) return false;
if (BegunAt > currentInstant) return false;
if (EndedAt.HasValue && currentInstant > EndedAt.Value) return false;
if (RenewalAt.HasValue && currentInstant > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Active) return false;
return true;
}
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
return BasePrice;
}
/// <summary>
/// Optimized method to calculate final price at a specific instant (avoids repeated SystemClock calls).
/// </summary>
public decimal CalculateFinalPriceAt(Instant currentInstant)
{
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice;
if (Coupon.AffectedAt.HasValue && currentInstant < Coupon.AffectedAt.Value ||
Coupon.ExpiredAt.HasValue && currentInstant > Coupon.ExpiredAt.Value) return BasePrice;
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
return BasePrice;
}
/// <summary>
@@ -184,6 +388,9 @@ public class SnWalletSubscription : ModelBase
/// </summary>
public SnSubscriptionReferenceObject ToReference()
{
// Cache the current instant once to avoid multiple SystemClock calls
var currentInstant = SystemClock.Instance.GetCurrentInstant();
return new SnSubscriptionReferenceObject
{
Id = Id,
@@ -191,11 +398,11 @@ public class SnWalletSubscription : ModelBase
BegunAt = BegunAt,
EndedAt = EndedAt,
IsActive = IsActive,
IsAvailable = IsAvailable,
IsAvailable = IsAvailableAt(currentInstant),
IsFreeTrial = IsFreeTrial,
Status = Status,
BasePrice = BasePrice,
FinalPrice = FinalPrice,
FinalPrice = CalculateFinalPriceAt(currentInstant),
RenewalAt = RenewalAt,
AccountId = AccountId
};
@@ -263,11 +470,13 @@ public class SnSubscriptionReferenceObject : ModelBase
public Instant? RenewalAt { get; set; }
public Guid AccountId { get; set; }
private string? _displayName;
/// <summary>
/// Gets the human-readable name of the subscription type if available.
/// Gets the human-readable name of the subscription type if available (cached for performance).
/// </summary>
[NotMapped]
public string? DisplayName => SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
public string? DisplayName => _displayName ??= SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
? name
: null;
@@ -281,8 +490,8 @@ public class SnSubscriptionReferenceObject : ModelBase
IsAvailable = IsAvailable,
IsFreeTrial = IsFreeTrial,
Status = (Proto.SubscriptionStatus)Status,
BasePrice = BasePrice.ToString(CultureInfo.CurrentCulture),
FinalPrice = FinalPrice.ToString(CultureInfo.CurrentCulture),
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
RenewalAt = RenewalAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
DisplayName = DisplayName,
@@ -401,4 +610,4 @@ public class SnWalletCoupon : ModelBase
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
}

View File

@@ -1,15 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json.Serialization;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models;
public class SnWallet : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public ICollection<SnWalletPocket> Pockets { get; set; } = new List<SnWalletPocket>();
public Guid AccountId { get; set; }
public SnAccount Account { get; set; } = null!;
@@ -42,7 +44,7 @@ public class SnWalletPocket : ModelBase
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public Guid WalletId { get; set; }
[JsonIgnore] public SnWallet Wallet { get; set; } = null!;
@@ -61,4 +63,97 @@ public class SnWalletPocket : ModelBase
Amount = decimal.Parse(proto.Amount),
WalletId = Guid.Parse(proto.WalletId),
};
}
}
public enum FundSplitType
{
Even,
Random
}
public enum FundStatus
{
Created,
PartiallyReceived,
FullyReceived,
Expired,
Refunded
}
public class SnWalletFund : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal TotalAmount { get; set; }
public FundSplitType SplitType { get; set; }
public FundStatus Status { get; set; } = FundStatus.Created;
[MaxLength(4096)] public string? Message { get; set; }
// Creator
public Guid CreatorAccountId { get; set; }
public SnAccount CreatorAccount { get; set; } = null!;
// Recipients
public ICollection<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>();
// Expiration
public Instant ExpiredAt { get; set; }
public Proto.WalletFund ToProtoValue() => new()
{
Id = Id.ToString(),
Currency = Currency,
TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture),
SplitType = (Proto.FundSplitType)SplitType,
Status = (Proto.FundStatus)Status,
Message = Message,
CreatorAccountId = CreatorAccountId.ToString(),
ExpiredAt = ExpiredAt.ToTimestamp(),
};
public static SnWalletFund FromProtoValue(Proto.WalletFund proto) => new()
{
Id = Guid.Parse(proto.Id),
Currency = proto.Currency,
TotalAmount = decimal.Parse(proto.TotalAmount),
SplitType = (FundSplitType)proto.SplitType,
Status = (FundStatus)proto.Status,
Message = proto.Message,
CreatorAccountId = Guid.Parse(proto.CreatorAccountId),
ExpiredAt = proto.ExpiredAt.ToInstant(),
};
}
public class SnWalletFundRecipient : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FundId { get; set; }
[JsonIgnore] public SnWalletFund Fund { get; set; } = null!;
public Guid RecipientAccountId { get; set; }
public SnAccount RecipientAccount { get; set; } = null!;
public decimal Amount { get; set; }
public bool IsReceived { get; set; } = false;
public Instant? ReceivedAt { get; set; }
public Proto.WalletFundRecipient ToProtoValue() => new()
{
Id = Id.ToString(),
FundId = FundId.ToString(),
RecipientAccountId = RecipientAccountId.ToString(),
Amount = Amount.ToString(CultureInfo.InvariantCulture),
IsReceived = IsReceived,
ReceivedAt = ReceivedAt?.ToTimestamp(),
};
public static SnWalletFundRecipient FromProtoValue(Proto.WalletFundRecipient proto) => new()
{
Id = Guid.Parse(proto.Id),
FundId = Guid.Parse(proto.FundId),
RecipientAccountId = Guid.Parse(proto.RecipientAccountId),
Amount = decimal.Parse(proto.Amount),
IsReceived = proto.IsReceived,
ReceivedAt = proto.ReceivedAt?.ToInstant(),
};
}

View File

@@ -14,232 +14,240 @@ import 'wallet.proto';
// Account represents a user account in the system
message Account {
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
string region = 18;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
string region = 18;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
AccountProfile profile = 7;
optional SubscriptionReferenceObject perk_subscription = 16;
repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13;
AccountProfile profile = 7;
optional SubscriptionReferenceObject perk_subscription = 16;
repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13;
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17;
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17;
}
// Enum for status attitude
enum StatusAttitude {
STATUS_ATTITUDE_UNSPECIFIED = 0;
POSITIVE = 1;
NEGATIVE = 2;
NEUTRAL = 3;
STATUS_ATTITUDE_UNSPECIFIED = 0;
POSITIVE = 1;
NEGATIVE = 2;
NEUTRAL = 3;
}
// AccountStatus represents the status of an account
message AccountStatus {
string id = 1;
StatusAttitude attitude = 2;
bool is_online = 3;
bool is_customized = 4;
bool is_invisible = 5;
bool is_not_disturb = 6;
google.protobuf.StringValue label = 7;
google.protobuf.Timestamp cleared_at = 8;
string account_id = 9;
bytes meta = 10;
string id = 1;
StatusAttitude attitude = 2;
bool is_online = 3;
bool is_customized = 4;
bool is_invisible = 5;
bool is_not_disturb = 6;
google.protobuf.StringValue label = 7;
google.protobuf.Timestamp cleared_at = 8;
string account_id = 9;
bytes meta = 10;
}
message UsernameColor {
string type = 1;
google.protobuf.StringValue value = 2;
google.protobuf.StringValue direction = 3;
repeated string colors = 4;
}
// Profile contains detailed information about a user
message AccountProfile {
string id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11;
string id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11;
VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13;
VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13;
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
double social_credits = 17;
int32 social_credits_level = 18;
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
double social_credits = 17;
int32 social_credits_level = 18;
CloudFile picture = 19;
CloudFile background = 20;
CloudFile picture = 19;
CloudFile background = 20;
string account_id = 21;
string account_id = 21;
google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23;
google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23;
optional UsernameColor username_color = 24;
}
// AccountContact represents a contact method for an account
message AccountContact {
string id = 1;
AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4;
string content = 5;
string account_id = 6;
string id = 1;
AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4;
string content = 5;
string account_id = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
// Enum for contact types
enum AccountContactType {
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1;
PHONE_NUMBER = 2;
ADDRESS = 3;
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1;
PHONE_NUMBER = 2;
ADDRESS = 3;
}
// AccountAuthFactor represents an authentication factor for an account
message AccountAuthFactor {
string id = 1;
AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7;
string account_id = 8;
map<string, google.protobuf.Value> created_response = 9; // For initial setup
string id = 1;
AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7;
string account_id = 8;
map<string, google.protobuf.Value> created_response = 9; // For initial setup
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
// Enum for authentication factor types
enum AccountAuthFactorType {
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1;
EMAIL_CODE = 2;
IN_APP_CODE = 3;
TIMED_CODE = 4;
PIN_CODE = 5;
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1;
EMAIL_CODE = 2;
IN_APP_CODE = 3;
TIMED_CODE = 4;
PIN_CODE = 5;
}
// AccountBadge represents a badge associated with an account
message AccountBadge {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
// AccountConnection represents a third-party connection for an account
message AccountConnection {
string id = 1;
string provider = 2;
string provided_identifier = 3;
map<string, google.protobuf.Value> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7;
string account_id = 8;
string id = 1;
string provider = 2;
string provided_identifier = 3;
map<string, google.protobuf.Value> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7;
string account_id = 8;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
// VerificationMark represents verification status
message VerificationMark {
VerificationMarkType type = 1;
string title = 2;
string description = 3;
string verified_by = 4;
VerificationMarkType type = 1;
string title = 2;
string description = 3;
string verified_by = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
}
enum VerificationMarkType {
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
OFFICIAL = 1;
INDIVIDUAL = 2;
ORGANIZATION = 3;
GOVERNMENT = 4;
CREATOR = 5;
DEVELOPER = 6;
PARODY = 7;
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
OFFICIAL = 1;
INDIVIDUAL = 2;
ORGANIZATION = 3;
GOVERNMENT = 4;
CREATOR = 5;
DEVELOPER = 6;
PARODY = 7;
}
// BadgeReferenceObject represents a reference to a badge with minimal information
message BadgeReferenceObject {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
}
// Relationship represents a connection between two accounts
message Relationship {
string account_id = 1;
string related_id = 2;
optional Account account = 3;
optional Account related = 4;
int32 status = 5;
string account_id = 1;
string related_id = 2;
optional Account account = 3;
optional Account related = 4;
int32 status = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// Leveling information
message LevelingInfo {
int32 current_level = 1;
int32 current_experience = 2;
int32 next_level_experience = 3;
int32 previous_level_experience = 4;
double level_progress = 5;
repeated int32 experience_per_level = 6;
int32 current_level = 1;
int32 current_experience = 2;
int32 next_level_experience = 3;
int32 previous_level_experience = 4;
double level_progress = 5;
repeated int32 experience_per_level = 6;
}
// ActionLog represents a record of an action taken by a user
message ActionLog {
string id = 1; // Unique identifier for the log entry
string action = 2; // The action that was performed, e.g., "user.login"
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
google.protobuf.StringValue user_agent = 4; // User agent of the client
google.protobuf.StringValue ip_address = 5; // IP address of the client
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
string account_id = 7; // The account that performed the action
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
string id = 1; // Unique identifier for the log entry
string action = 2; // The action that was performed, e.g., "user.login"
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
google.protobuf.StringValue user_agent = 4; // User agent of the client
google.protobuf.StringValue ip_address = 5; // IP address of the client
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
string account_id = 7; // The account that performed the action
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
google.protobuf.Timestamp created_at = 9; // When the action log was created
google.protobuf.Timestamp created_at = 9; // When the action log was created
}
message GetAccountStatusBatchResponse {
repeated AccountStatus statuses = 1;
repeated AccountStatus statuses = 1;
}
// ====================================
@@ -248,45 +256,46 @@ message GetAccountStatusBatchResponse {
// AccountService provides CRUD operations for user accounts and related entities
service AccountService {
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
// Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
// Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
// Contact Operations
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
// Contact Operations
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
// Badge Operations
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
// Badge Operations
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
// Authentication Factor Operations
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Authentication Factor Operations
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Connection Operations
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Connection Operations
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Relationship Operations
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
// Relationship Operations
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
}
// ActionLogService provides operations for action logs
service ActionLogService {
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
}
// ====================================
@@ -295,184 +304,188 @@ service ActionLogService {
// ActionLog Requests/Responses
message CreateActionLogRequest {
string action = 1;
map<string, google.protobuf.Value> meta = 2;
google.protobuf.StringValue user_agent = 3;
google.protobuf.StringValue ip_address = 4;
google.protobuf.StringValue location = 5;
string account_id = 6;
google.protobuf.StringValue session_id = 7;
string action = 1;
map<string, google.protobuf.Value> meta = 2;
google.protobuf.StringValue user_agent = 3;
google.protobuf.StringValue ip_address = 4;
google.protobuf.StringValue location = 5;
string account_id = 6;
google.protobuf.StringValue session_id = 7;
}
message CreateActionLogResponse {
ActionLog action_log = 1;
ActionLog action_log = 1;
}
message ListActionLogsRequest {
string account_id = 1;
string action = 2;
int32 page_size = 3;
string page_token = 4;
string order_by = 5;
string account_id = 1;
string action = 2;
int32 page_size = 3;
string page_token = 4;
string order_by = 5;
}
message ListActionLogsResponse {
repeated ActionLog action_logs = 1;
string next_page_token = 2;
int32 total_size = 3;
repeated ActionLog action_logs = 1;
string next_page_token = 2;
int32 total_size = 3;
}
// Account Requests/Responses
message GetAccountRequest {
string id = 1; // Account ID to retrieve
string id = 1; // Account ID to retrieve
}
message GetBotAccountRequest {
string automated_id = 1;
string automated_id = 1;
}
message GetAccountBatchRequest {
repeated string id = 1; // Account ID to retrieve
repeated string id = 1; // Account ID to retrieve
}
message GetBotAccountBatchRequest {
repeated string automated_id = 1;
repeated string automated_id = 1;
}
message LookupAccountBatchRequest {
repeated string names = 1;
repeated string names = 1;
}
message SearchAccountRequest {
string query = 1;
}
message GetAccountBatchResponse {
repeated Account accounts = 1; // List of accounts
repeated Account accounts = 1; // List of accounts
}
message CreateAccountRequest {
string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name
string language = 3; // Default language
bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data
string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name
string language = 3; // Default language
bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data
}
message UpdateAccountRequest {
string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status
string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status
}
message DeleteAccountRequest {
string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete
string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete
}
message ListAccountsRequest {
int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination
string filter = 3; // Filter expression
string order_by = 4; // Sort order
int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination
string filter = 3; // Filter expression
string order_by = 4; // Sort order
}
message ListAccountsResponse {
repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts
repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts
}
// Profile Requests/Responses
message GetProfileRequest {
string account_id = 1; // Account ID to get profile for
string account_id = 1; // Account ID to get profile for
}
message UpdateProfileRequest {
string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update
string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update
}
// Contact Requests/Responses
message AddContactRequest {
string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact
string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact
}
message ListContactsRequest {
string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts
string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts
}
message ListContactsResponse {
repeated AccountContact contacts = 1; // List of contacts
repeated AccountContact contacts = 1; // List of contacts
}
message VerifyContactRequest {
string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code
string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code
}
// Badge Requests/Responses
message ListBadgesRequest {
string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges
string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges
}
message ListBadgesResponse {
repeated AccountBadge badges = 1; // List of badges
repeated AccountBadge badges = 1; // List of badges
}
message ListAuthFactorsRequest {
string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors
string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors
}
message ListAuthFactorsResponse {
repeated AccountAuthFactor factors = 1; // List of auth factors
repeated AccountAuthFactor factors = 1; // List of auth factors
}
message ListConnectionsRequest {
string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider
string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider
}
message ListConnectionsResponse {
repeated AccountConnection connections = 1; // List of connections
repeated AccountConnection connections = 1; // List of connections
}
// Relationship Requests/Responses
message ListRelationshipsRequest {
string account_id = 1; // Account to list relationships for
optional int32 status = 2; // Filter by status
int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination
string account_id = 1; // Account to list relationships for
optional int32 status = 2; // Filter by status
int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination
}
message ListRelationshipsResponse {
repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships
repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships
}
message GetRelationshipRequest {
string account_id = 1;
string related_id = 2;
optional int32 status = 3;
string account_id = 1;
string related_id = 2;
optional int32 status = 3;
}
message GetRelationshipResponse {
optional Relationship relationship = 1;
optional Relationship relationship = 1;
}
message ListRelationshipSimpleRequest {
string account_id = 1;
string account_id = 1;
}
message ListRelationshipSimpleResponse {
repeated string accounts_id = 1;
repeated string accounts_id = 1;
}

View File

@@ -115,8 +115,8 @@ message BotAccount {
message CreateBotAccountRequest {
Account account = 1;
string automated_id = 2;
optional string picture_id = 8;
optional string background_id = 9;
google.protobuf.StringValue picture_id = 8;
google.protobuf.StringValue background_id = 9;
}
message CreateBotAccountResponse {
@@ -182,4 +182,4 @@ service BotAccountReceiverService {
rpc RotateApiKey(GetApiKeyRequest) returns (ApiKey);
rpc DeleteApiKey(GetApiKeyRequest) returns (DeleteApiKeyResponse);
}

View File

@@ -22,6 +22,42 @@ message WalletPocket {
string wallet_id = 4;
}
enum FundSplitType {
FUND_SPLIT_TYPE_UNSPECIFIED = 0;
FUND_SPLIT_TYPE_EVEN = 1;
FUND_SPLIT_TYPE_RANDOM = 2;
}
enum FundStatus {
FUND_STATUS_UNSPECIFIED = 0;
FUND_STATUS_CREATED = 1;
FUND_STATUS_PARTIALLY_RECEIVED = 2;
FUND_STATUS_FULLY_RECEIVED = 3;
FUND_STATUS_EXPIRED = 4;
FUND_STATUS_REFUNDED = 5;
}
message WalletFund {
string id = 1;
string currency = 2;
string total_amount = 3;
FundSplitType split_type = 4;
FundStatus status = 5;
optional string message = 6;
string creator_account_id = 7;
google.protobuf.Timestamp expired_at = 8;
repeated WalletFundRecipient recipients = 9;
}
message WalletFundRecipient {
string id = 1;
string fund_id = 2;
string recipient_account_id = 3;
string amount = 4;
bool is_received = 5;
optional google.protobuf.Timestamp received_at = 6;
}
enum SubscriptionStatus {
// Using proto3 enum naming convention
SUBSCRIPTION_STATUS_UNSPECIFIED = 0;
@@ -31,6 +67,16 @@ enum SubscriptionStatus {
SUBSCRIPTION_STATUS_CANCELLED = 4;
}
enum GiftStatus {
// Using proto3 enum naming convention
GIFT_STATUS_UNSPECIFIED = 0;
GIFT_STATUS_CREATED = 1;
GIFT_STATUS_SENT = 2;
GIFT_STATUS_REDEEMED = 3;
GIFT_STATUS_EXPIRED = 4;
GIFT_STATUS_CANCELLED = 5;
}
message Subscription {
string id = 1;
google.protobuf.Timestamp begun_at = 2;
@@ -93,6 +139,31 @@ message Coupon {
google.protobuf.Timestamp updated_at = 10;
}
message Gift {
string id = 1;
string gifter_id = 2;
optional string recipient_id = 3;
string gift_code = 4;
optional string message = 5;
string subscription_identifier = 6;
string base_price = 7;
string final_price = 8;
GiftStatus status = 9;
optional google.protobuf.Timestamp redeemed_at = 10;
optional string redeemer_id = 11;
optional string subscription_id = 12;
google.protobuf.Timestamp expires_at = 13;
bool is_open_gift = 14;
string payment_method = 15;
PaymentDetails payment_details = 16;
optional string coupon_id = 17;
optional Coupon coupon = 18;
bool is_redeemable = 19;
bool is_expired = 20;
google.protobuf.Timestamp created_at = 21;
google.protobuf.Timestamp updated_at = 22;
}
service WalletService {
rpc GetWallet(GetWalletRequest) returns (Wallet);
rpc CreateWallet(CreateWalletRequest) returns (Wallet);

View File

@@ -11,7 +11,7 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountAsync(request);
return response;
}
public async Task<Account> GetBotAccount(Guid automatedId)
{
var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() };
@@ -26,7 +26,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountBatchAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> SearchAccounts(string query)
{
var request = new SearchAccountRequest { Query = query };
var response = await accounts.SearchAccountAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
{
var request = new GetBotAccountBatchRequest();

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using NodaTime;
namespace DysonNetwork.Shared.Stream;
@@ -5,7 +6,16 @@ namespace DysonNetwork.Shared.Stream;
public class AccountDeletedEvent
{
public static string Type => "account_deleted";
public Guid AccountId { get; set; } = Guid.NewGuid();
public Instant DeletedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
}
}
public class AccountStatusUpdatedEvent
{
public static string Type => "account_status_updated";
public Guid AccountId { get; set; }
public SnAccountStatus Status { get; set; } = new();
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
}

View File

@@ -1,3 +1,5 @@
using NodaTime;
namespace DysonNetwork.Shared.Stream;
public class WebSocketPacketEvent
@@ -10,3 +12,23 @@ public class WebSocketPacketEvent
public string DeviceId { get; set; } = null!;
public byte[] PacketBytes { get; set; } = null!;
}
public class WebSocketConnectedEvent
{
public static string Type => "websocket_connected";
public Guid AccountId { get; set; }
public string DeviceId { get; set; } = null!;
public Instant ConnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
public bool IsOffline { get; set; } = false;
}
public class WebSocketDisconnectedEvent
{
public static string Type => "websocket_disconnected";
public Guid AccountId { get; set; }
public string DeviceId { get; set; } = null!;
public Instant DisconnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
public bool IsOffline { get; set; }
}

View File

@@ -0,0 +1,20 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Autocompletion;
[ApiController]
[Route("/api/autocomplete")]
public class AutocompletionController(AutocompletionService aus) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> TextAutocomplete([FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

View File

@@ -0,0 +1,146 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Autocompletion;
public class AutocompletionService(AppDatabase db, AccountClientHelper accountsHelper)
{
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
{
if (string.IsNullOrWhiteSpace(content))
return [];
if (content.StartsWith('@'))
{
var afterAt = content[1..];
string type;
string query;
var hadSlash = afterAt.Contains('/');
if (hadSlash)
{
var parts = afterAt.Split('/', 2);
type = parts[0];
query = parts.Length > 1 ? parts[1] : "";
}
else
{
type = "u";
query = afterAt;
}
return await AutocompleteAt(type, query, chatId, realmId, hadSlash, limit);
}
if (!content.StartsWith(':')) return [];
{
var query = content[1..];
return await AutocompleteSticker(query, limit);
}
}
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, Guid? chatId, Guid? realmId, bool hadSlash,
int limit)
{
var results = new List<DysonNetwork.Shared.Models.Autocompletion>();
switch (type)
{
case "u":
var allAccounts = await accountsHelper.SearchAccounts(query);
var filteredAccounts = allAccounts;
if (chatId.HasValue)
{
var chatMemberIds = await db.ChatMembers
.Where(m => m.ChatRoomId == chatId.Value && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var chatMemberIdStrings = chatMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => chatMemberIdStrings.Contains(a.Id)).ToList();
}
else if (realmId.HasValue)
{
var realmMemberIds = await db.RealmMembers
.Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
}
var users = filteredAccounts
.Take(limit)
.Select(a => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "user",
Keyword = "@" + (hadSlash ? "u/" : "") + a.Name,
Data = SnAccount.FromProtoValue(a)
})
.ToList();
results.AddRange(users);
break;
case "p":
var publishers = await db.Publishers
.Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%"))
.Take(limit)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "publisher",
Keyword = "@p/" + p.Name,
Data = p
})
.ToListAsync();
results.AddRange(publishers);
break;
case "r":
var realms = await db.Realms
.Where(r => EF.Functions.Like(r.Slug, $"{query}%") || EF.Functions.Like(r.Name, $"{query}%"))
.Take(limit)
.Select(r => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "realm",
Keyword = "@r/" + r.Slug,
Data = r
})
.ToListAsync();
results.AddRange(realms);
break;
case "c":
var chats = await db.ChatRooms
.Where(c => c.Name != null && EF.Functions.Like(c.Name, $"{query}%"))
.Take(limit)
.Select(c => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "chat",
Keyword = "@c/" + c.Name,
Data = c
})
.ToListAsync();
results.AddRange(chats);
break;
}
return results;
}
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteSticker(string query, int limit)
{
var stickers = await db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.Take(limit)
.Select(s => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker",
Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
Data = s
})
.ToListAsync();
var results = stickers.ToList();
return results;
}
}

View File

@@ -1,9 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Content;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Autocompletion;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -17,7 +18,8 @@ public partial class ChatController(
ChatService cs,
ChatRoomService crs,
FileService.FileServiceClient files,
AccountService.AccountServiceClient accounts
AccountService.AccountServiceClient accounts,
AutocompletionService aus
) : ControllerBase
{
public class MarkMessageReadRequest
@@ -85,7 +87,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
@@ -101,10 +104,10 @@ public partial class ChatController(
.Skip(offset)
.Take(take)
.ToListAsync();
var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
members = await crs.LoadMemberAccounts(members);
foreach (var message in messages)
message.Sender = members.First(x => x.Id == message.SenderId);
@@ -127,7 +130,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
@@ -139,16 +143,81 @@ public partial class ChatController(
.FirstOrDefaultAsync();
if (message is null) return NotFound();
message.Sender = await crs.LoadMemberAccount(message.Sender);
return Ok(message);
}
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex();
/// <summary>
/// Extracts mentioned users from message content, replies, and forwards
/// </summary>
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
{
var mentionedUsers = new List<Guid>();
// Add sender of a replied message
if (repliedMessageId.HasValue)
{
var replyingTo = await db.ChatMessages
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Select(m => m.Sender)
.FirstOrDefaultAsync();
if (replyingTo != null)
mentionedUsers.Add(replyingTo.AccountId);
}
// Add sender of a forwarded message
if (forwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.Where(m => m.Id == forwardedMessageId.Value)
.Select(m => new { m.SenderId })
.FirstOrDefaultAsync();
if (forwardedMessage != null)
{
mentionedUsers.Add(forwardedMessage.SenderId);
}
}
// Extract mentions from content using regex
if (!string.IsNullOrWhiteSpace(content))
{
var mentionedNames = MentionRegex()
.Matches(content)
.Select(m => m.Groups[1].Value)
.Distinct()
.ToList();
if (mentionedNames.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentionedNames);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
if (mentionedIds.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
.Select(m => m.AccountId)
.ToListAsync();
mentionedUsers.AddRange(mentionedMembers);
}
}
}
return mentionedUsers.Distinct().ToList();
}
[HttpPost("{roomId:guid}/messages")]
[Authorize]
[RequiredPermission("global", "chat.messages.create")]
@@ -186,6 +255,7 @@ public partial class ChatController(
.ToList();
}
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
@@ -206,27 +276,9 @@ public partial class ChatController(
message.ForwardedMessageId = forwardedMessage.Id;
}
if (request.Content is not null)
{
var mentioned = MentionRegex()
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentioned);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedId = queryResponse
.Select(a => Guid.Parse(a.Id))
.ToList();
var mentionedMembers = await db.ChatMembers
.Where(m => mentionedId.Contains(m.AccountId))
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
// Extract mentioned users
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId);
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
@@ -256,6 +308,7 @@ public partial class ChatController(
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message.");
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
@@ -272,6 +325,11 @@ public partial class ChatController(
return BadRequest("The message you're forwarding does not exist.");
}
// Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions;
// Call service method to update the message
await cs.UpdateMessageAsync(
message,
@@ -321,11 +379,30 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId);
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
return Ok(response);
}
[HttpPost("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

View File

@@ -56,8 +56,7 @@ public class ChatRoomController(
var chatRooms = await db.ChatMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Include(m => m.ChatRoom)
.Select(m => m.ChatRoom)
.ToListAsync();
@@ -166,7 +165,7 @@ public class ChatRoomController(
public class ChatRoomRequest
{
[Required] [MaxLength(1024)] public string? Name { get; set; }
[Required][MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
@@ -475,6 +474,7 @@ public class ChatRoomController(
var member = await db.ChatMembers
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null)
@@ -483,6 +483,37 @@ public class ChatRoomController(
return Ok(await crs.LoadMemberAccount(member));
}
[HttpGet("{roomId:guid}/members/online")]
public async Task<ActionResult<int>> GetOnlineUsersCount(Guid roomId)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account;
var room = await db.ChatRooms
.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!room.IsPublic)
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return StatusCode(403, "You need to be a member to see online count of private chat room.");
}
var members = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch(members);
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
return Ok(onlineCount);
}
[HttpGet("{roomId:guid}/members")]
public async Task<ActionResult<List<SnChatMember>>> ListMembers(Guid roomId,
[FromQuery] int take = 20,
@@ -500,13 +531,14 @@ public class ChatRoomController(
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id));
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
}
var query = db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.LeaveAt == null);
.Where(m => m.JoinedAt != null && m.LeaveAt == null);
if (withStatus)
{
@@ -603,6 +635,7 @@ public class ChatRoomController(
var chatMember = await db.ChatMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
if (chatMember.Role < ChatMemberRole.Moderator)
@@ -612,13 +645,36 @@ public class ChatRoomController(
return StatusCode(403, "You cannot invite member with higher permission than yours.");
}
var hasExistingMember = await db.ChatMembers
var existingMember = await db.ChatMembers
.Where(m => m.AccountId == request.RelatedUserId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.LeaveAt == null)
.AnyAsync();
if (hasExistingMember)
return BadRequest("This user has been joined the chat cannot be invited again.");
.FirstOrDefaultAsync();
if (existingMember != null)
{
if (existingMember.LeaveAt == null)
return BadRequest("This user has been joined the chat cannot be invited again.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = null;
db.ChatMembers.Update(existingMember);
await db.SaveChangesAsync();
await _SendInviteNotify(existingMember, currentUser);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "chatrooms.invite",
Meta =
{
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(existingMember);
}
var newMember = new SnChatMember
{
@@ -745,7 +801,7 @@ public class ChatRoomController(
var accountId = Guid.Parse(currentUser.Id);
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (targetMember is null) return BadRequest("You have not joined this chat room.");
if (request.NotifyLevel is not null)
@@ -786,7 +842,7 @@ public class ChatRoomController(
else
{
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (targetMember is null) return NotFound();
@@ -854,7 +910,7 @@ public class ChatRoomController(
// Find the target member
var member = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
@@ -899,7 +955,17 @@ public class ChatRoomController(
var existingMember = await db.ChatMembers
.FirstOrDefaultAsync(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId);
if (existingMember != null)
return BadRequest("You are already a member of this chat room.");
{
if (existingMember.LeaveAt == null)
return BadRequest("You are already a member of this chat room.");
existingMember.LeaveAt = null;
db.Update(existingMember);
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
return Ok(existingMember);
}
var newMember = new SnChatMember
{
@@ -932,6 +998,7 @@ public class ChatRoomController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
.Where(m => m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
@@ -951,6 +1018,7 @@ public class ChatRoomController(
}
member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
db.Update(member);
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
@@ -970,7 +1038,7 @@ public class ChatRoomController(
{
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account);
string title = localizer["ChatInviteTitle"];
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage

View File

@@ -45,7 +45,8 @@ public class ChatRoomService(
if (member is not null) return member;
member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null &&
m.LeaveAt == null)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync();
@@ -95,7 +96,7 @@ public class ChatRoomService(
? await db.ChatMembers
.Where(m => directRoomsId.Contains(m.ChatRoomId))
.Where(m => m.AccountId != userId)
.Where(m => m.LeaveAt == null)
// Ignored the joined at condition here to keep showing userinfo when other didn't accept the invite of DM
.ToListAsync()
: [];
members = await LoadMemberAccounts(members);
@@ -121,7 +122,7 @@ public class ChatRoomService(
if (room.Type != ChatRoomType.DirectMessage) return room;
var members = await db.ChatMembers
.Where(m => m.ChatRoomId == room.Id && m.AccountId != userId)
.Where(m => m.LeaveAt == null)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.ToListAsync();
if (members.Count <= 0) return room;
@@ -139,7 +140,8 @@ public class ChatRoomService(
var maxRequiredRole = requiredRoles.Max();
var member = await db.ChatMembers
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == accountId);
.Where(m => m.ChatRoomId == roomId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
return member?.Role >= maxRequiredRole;
}
@@ -155,11 +157,43 @@ public class ChatRoomService(
var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
return [.. members.Select(m =>
{
if (accounts.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account);
return m;
})];
return
[
.. members.Select(m =>
{
if (accounts.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account);
return m;
})
];
}
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";
public async Task SubscribeChatRoom(SnChatMember member)
{
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
await cache.SetAsync(cacheKey, true, TimeSpan.FromHours(1));
await cache.AddToGroupAsync(cacheKey, $"chatroom:subscribers:{member.ChatRoomId}");
}
public async Task UnsubscribeChatRoom(SnChatMember member)
{
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
await cache.RemoveAsync(cacheKey);
}
public async Task<bool> IsSubscribedChatRoom(Guid roomId, Guid memberId)
{
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{roomId}:{memberId}";
var result = await cache.GetAsync<bool?>(cacheKey);
return result ?? false;
}
public async Task<List<Guid>> GetSubscribedMembers(Guid roomId)
{
var group = $"chatroom:subscribers:{roomId}";
var keys = await cache.GetGroupKeysAsync(group);
return keys.Select(k => Guid.Parse(k.Split(':').Last())).ToList();
}
}

View File

@@ -198,8 +198,6 @@ public partial class ChatService(
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
{
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation
db.ChatMessages.Add(message);
@@ -209,20 +207,25 @@ public partial class ChatService(
await CreateFileReferencesForMessageAsync(message);
// Then start the delivery process
var localMessage = message;
var localSender = sender;
var localRoom = room;
var localLogger = logger;
_ = Task.Run(async () =>
{
try
{
await DeliverMessageAsync(message, sender, room);
await DeliverMessageAsync(localMessage, localSender, localRoom);
}
catch (Exception ex)
{
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
}
});
// Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
var localMessageForPreview = message;
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
message.Sender = sender;
message.ChatRoom = room;
@@ -280,7 +283,16 @@ public partial class ChatService(
var accountsToNotify = FilterAccountsForNotification(members, message, sender);
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
// Filter out subscribed users from push notifications
var subscribedMemberIds = new List<Guid>();
foreach (var member in members)
{
if (await scopedCrs.IsSubscribedChatRoom(member.ChatRoomId, member.Id))
subscribedMemberIds.Add(member.AccountId);
}
accountsToNotify = accountsToNotify.Where(a => !subscribedMemberIds.Contains(Guid.Parse(a.Id))).ToList();
logger.LogInformation("Trying to deliver message to {count} accounts...", accountsToNotify.Count);
if (accountsToNotify.Count > 0)
{
@@ -289,7 +301,7 @@ public partial class ChatService(
await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
}
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
logger.LogInformation("Delivered message to {count} accounts.", accountsToNotify.Count);
}
private PushNotification BuildNotification(SnChatMessage message, SnChatMember sender, SnChatRoom room, string roomSubject,
@@ -432,7 +444,7 @@ public partial class ChatService(
public async Task ReadChatRoomAsync(Guid roomId, Guid userId)
{
var sender = await db.ChatMembers
.Where(m => m.AccountId == userId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == userId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (sender is null) throw new ArgumentException("User is not a member of the chat room.");
@@ -443,7 +455,7 @@ public partial class ChatService(
public async Task<int> CountUnreadMessage(Guid userId, Guid chatRoomId)
{
var sender = await db.ChatMembers
.Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId)
.Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => new { m.LastReadAt })
.FirstOrDefaultAsync();
if (sender?.LastReadAt is null) return 0;

View File

@@ -52,7 +52,7 @@ public class RealtimeCallController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
@@ -78,7 +78,7 @@ public class RealtimeCallController(
// Check if the user is a member of the chat room
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
@@ -151,7 +151,7 @@ public class RealtimeCallController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
@@ -171,7 +171,7 @@ public class RealtimeCallController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a normal member to end a call.");
@@ -256,4 +256,4 @@ public class CallParticipant
/// When the participant joined the call
/// </summary>
public DateTime JoinedAt { get; set; }
}
}

View File

@@ -16,7 +16,7 @@
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
<PackageReference Include="HtmlAgilityPack" Version="1.12.1"/>
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
<PackageReference Include="Markdig" Version="0.41.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7"/>
@@ -122,7 +122,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup>

View File

@@ -1,9 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Content;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader;
@@ -23,6 +24,7 @@ public class PostController(
AppDatabase db,
PostService ps,
PublisherService pub,
AccountClientHelper accountsHelper,
AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
@@ -97,7 +99,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -197,7 +199,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -228,7 +230,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -271,6 +273,14 @@ public class PostController(
.Take(take)
.Skip(offset)
.ToListAsync();
var accountsProto = await accountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
foreach (var reaction in reactions)
if (accounts.TryGetValue(reaction.AccountId, out var account))
reaction.Account = account;
return Ok(reactions);
}
@@ -283,7 +293,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -314,7 +324,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -342,7 +352,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -448,7 +458,10 @@ public class PostController(
if (request.RepliedPostId is not null)
{
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value);
var repliedPost = await db.Posts
.Where(p => p.Id == request.RepliedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (repliedPost is null) return BadRequest("Post replying to was not found.");
post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id;
@@ -456,7 +469,10 @@ public class PostController(
if (request.ForwardedPostId is not null)
{
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value);
var forwardedPost = await db.Posts
.Where(p => p.Id == request.ForwardedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id;
@@ -513,6 +529,8 @@ public class PostController(
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
post.Publisher = publisher;
return post;
}
@@ -522,6 +540,9 @@ public class PostController(
public PostReactionAttitude Attitude { get; set; }
}
public static readonly List<string> ReactionsAllowedDefault =
["thumb_up", "thumb_down", "just_okay", "cry", "confuse", "clap", "laugh", "angry", "party", "pray", "heart"];
[HttpPost("{id:guid}/reactions")]
[Authorize]
[RequiredPermission("global", "posts.react")]
@@ -531,10 +552,14 @@ public class PostController(
var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() });
{ AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
if (!ReactionsAllowedDefault.Contains(request.Symbol))
if (currentUser.PerkSubscription is null)
return BadRequest("You need subscription to send custom reactions");
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
@@ -623,7 +648,7 @@ public class PostController(
var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() });
{ AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -906,4 +931,4 @@ public class PostController(
return NoContent();
}
}
}

View File

@@ -14,7 +14,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppServices();
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();

View File

@@ -37,6 +37,14 @@ public class PublisherController(
return Ok(publisher);
}
[HttpGet("{name}/heatmap")]
public async Task<ActionResult<ActivityHeatmap>> GetPublisherHeatmap(string name)
{
var heatmap = await ps.GetPublisherHeatmap(name);
if (heatmap is null) return NotFound();
return Ok(heatmap);
}
[HttpGet("{name}/stats")]
public async Task<ActionResult<PublisherService.PublisherStats>> GetPublisherStats(string name)
@@ -693,4 +701,4 @@ public class PublisherController(
return NoContent();
}
}
}

View File

@@ -282,8 +282,9 @@ public class PublisherService(
public int SubscribersCount { get; set; }
}
private const string PublisherStatsCacheKey = "PublisherStats_{0}";
private const string PublisherFeatureCacheKey = "PublisherFeature_{0}_{1}";
private const string PublisherStatsCacheKey = "publisher:{0}:stats";
private const string PublisherHeatmapCacheKey = "publisher:{0}:heatmap";
private const string PublisherFeatureCacheKey = "publisher:{0}:feature:{1}";
public async Task<PublisherStats?> GetPublisherStats(string name)
{
@@ -325,6 +326,45 @@ public class PublisherService(
return stats;
}
public async Task<ActivityHeatmap?> GetPublisherHeatmap(string name)
{
var cacheKey = string.Format(PublisherHeatmapCacheKey, name);
var heatmap = await cache.GetAsync<ActivityHeatmap?>(cacheKey);
if (heatmap is not null)
return heatmap;
var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name);
if (publisher is null) return null;
var now = SystemClock.Instance.GetCurrentInstant();
var periodStart = now.Minus(Duration.FromDays(365));
var periodEnd = now;
var postGroups = await db.Posts
.Where(p => p.PublisherId == publisher.Id && p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd)
.Select(p => p.CreatedAt.InUtc().Date)
.GroupBy(d => d)
.Select(g => new { Date = g.Key, Count = g.Count() })
.ToListAsync();
var items = postGroups.Select(p => new ActivityHeatmapItem
{
Date = p.Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(),
Count = p.Count
}).ToList();
heatmap = new ActivityHeatmap
{
Unit = "posts",
PeriodStart = periodStart,
PeriodEnd = periodEnd,
Items = items.OrderBy(i => i.Date).ToList()
};
await cache.SetAsync(cacheKey, heatmap, TimeSpan.FromMinutes(5));
return heatmap;
}
public async Task SetFeatureFlag(Guid publisherId, string flag)
{
var featureFlag = await db.PublisherFeatures
@@ -397,4 +437,4 @@ public class PublisherService(
return m;
})];
}
}
}

View File

@@ -42,8 +42,7 @@ public class RealmController(
var members = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Include(e => e.Realm)
.Select(m => m.Realm)
.ToListAsync();
@@ -102,13 +101,37 @@ public class RealmController(
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers
var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id))
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null)
.AnyAsync();
if (hasExistingMember)
return BadRequest("This user has been joined the realm or leave cannot be invited again.");
.FirstOrDefaultAsync();
if (existingMember != null)
{
if (existingMember.LeaveAt == null)
return BadRequest("This user already in the realm cannot be invited again.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = null;
db.RealmMembers.Update(existingMember);
await db.SaveChangesAsync();
await rs.SendInviteNotify(existingMember);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.invite",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(existingMember);
}
var member = new SnRealmMember
{
@@ -232,7 +255,7 @@ public class RealmController(
var query = db.RealmMembers
.Where(m => m.RealmId == realm.Id)
.Where(m => m.LeaveAt == null);
.Where(m => m.JoinedAt != null && m.LeaveAt == null);
if (withStatus)
{
@@ -289,6 +312,7 @@ public class RealmController(
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
@@ -305,7 +329,7 @@ public class RealmController(
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.Realm.Slug == slug)
.Where(m => m.JoinedAt != null)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
@@ -444,7 +468,7 @@ public class RealmController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null)
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You do not have permission to update this realm.");
@@ -558,7 +582,32 @@ public class RealmController(
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (existingMember is not null)
return BadRequest("You are already a member of this realm.");
{
if (existingMember.LeaveAt == null)
return BadRequest("You are already a member of this realm.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(existingMember);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(existingMember);
}
var member = new SnRealmMember
{
@@ -600,7 +649,7 @@ public class RealmController(
if (realm is null) return NotFound();
var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
@@ -640,7 +689,7 @@ public class RealmController(
if (realm is null) return NotFound();
var member = await db.RealmMembers
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();

View File

@@ -30,6 +30,7 @@ public class RealmService(
var realms = await db.RealmMembers
.Include(m => m.Realm)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.Realm!.Id)
.ToListAsync();
@@ -67,7 +68,8 @@ public class RealmService(
var maxRequiredRole = requiredRoles.Max();
var member = await db.RealmMembers
.FirstOrDefaultAsync(m => m.RealmId == realmId && m.AccountId == accountId);
.Where(m => m.RealmId == realmId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
return member?.Role >= maxRequiredRole;
}
@@ -90,4 +92,4 @@ public class RealmService(
return m;
}).ToList();
}
}
}

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Post;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
@@ -39,8 +40,9 @@ public class BroadcastEventHandler(
var paymentTask = HandlePaymentOrders(stoppingToken);
var accountTask = HandleAccountDeletions(stoppingToken);
var websocketTask = HandleWebSocketPackets(stoppingToken);
var accountStatusTask = HandleAccountStatusUpdates(stoppingToken);
await Task.WhenAll(paymentTask, accountTask, websocketTask);
await Task.WhenAll(paymentTask, accountTask, websocketTask, accountStatusTask);
}
private async Task HandlePaymentOrders(CancellationToken stoppingToken)
@@ -192,6 +194,12 @@ public class BroadcastEventHandler(
case "messages.typing":
await HandleMessageTyping(evt, packet);
break;
case "messages.subscribe":
await HandleMessageSubscribe(evt, packet);
break;
case "messages.unsubscribe":
await HandleMessageUnsubscribe(evt, packet);
break;
}
}
catch (Exception ex)
@@ -278,6 +286,123 @@ public class BroadcastEventHandler(
await pusher.PushWebSocketPacketToUsersAsync(respRequest);
}
private async Task HandleMessageSubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.SubscribeChatRoom(sender);
}
private async Task HandleMessageUnsubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.UnsubscribeChatRoom(sender);
}
private async Task HandleAccountStatusUpdates(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountStatusUpdatedEvent.Type, cancellationToken: stoppingToken))
{
try
{
var evt = GrpcTypeHelper.ConvertByteStringToObject<AccountStatusUpdatedEvent>(ByteString.CopyFrom(msg.Data));
if (evt == null)
continue;
logger.LogInformation("Account status updated: {AccountId}", evt.AccountId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var chatRoomService = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
// Get user's joined chat rooms
var userRooms = await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.ChatRoomId)
.ToListAsync(cancellationToken: stoppingToken);
// Send WebSocket packet to subscribed users per room
foreach (var roomId in userRooms)
{
var members = await chatRoomService.ListRoomMembers(roomId);
var subscribedMemberIds = await chatRoomService.GetSubscribedMembers(roomId);
var subscribedUsers = members
.Where(m => subscribedMemberIds.Contains(m.Id))
.Select(m => m.AccountId.ToString())
.ToList();
if (subscribedUsers.Count == 0) continue;
var packet = new WebSocketPacket
{
Type = "accounts.status.update",
Data = new Dictionary<string, object>
{
["status"] = evt.Status,
["chat_room_id"] = roomId
}
};
var request = new PushWebSocketPacketToUsersRequest
{
Packet = packet.ToProtoValue()
};
request.UserIds.AddRange(subscribedUsers);
await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken);
logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId, subscribedUsers.Count);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountStatusUpdated");
}
}
}
private async Task SendErrorResponse(WebSocketPacketEvent evt, string message)
{
await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest

View File

@@ -10,27 +10,24 @@ public static class ScheduledJobsConfiguration
{
services.AddQuartz(q =>
{
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity("AppDatabaseRecycling"));
q.AddTrigger(opts => opts
.ForJob(appDatabaseRecyclingJob)
.ForJob("AppDatabaseRecycling")
.WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?"));
var postViewFlushJob = new JobKey("PostViewFlush");
q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity(postViewFlushJob));
q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity("PostViewFlush"));
q.AddTrigger(opts => opts
.ForJob(postViewFlushJob)
.ForJob("PostViewFlush")
.WithIdentity("PostViewFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(1)
.RepeatForever())
);
var webFeedScraperJob = new JobKey("WebFeedScraper");
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity(webFeedScraperJob).StoreDurably());
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity("WebFeedScraper").StoreDurably());
q.AddTrigger(opts => opts
.ForJob(webFeedScraperJob)
.ForJob("WebFeedScraper")
.WithIdentity("WebFeedScraperTrigger")
.WithCronSchedule("0 0 0 * * ?")
);

View File

@@ -15,6 +15,8 @@ using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Autocompletion;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Poll;
@@ -24,7 +26,7 @@ namespace DysonNetwork.Sphere.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddAppServices(this IServiceCollection services)
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
@@ -39,7 +41,6 @@ public static class ServiceCollectionExtensions
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options =>
@@ -118,6 +119,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>();
services.AddScoped<AccountClientHelper>();
services.AddScoped<AutocompletionService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();
switch (translationProvider)
@@ -129,4 +132,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}

View File

@@ -237,6 +237,22 @@ public class StickerController(
return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
}
[HttpGet("search")]
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
{
var queryable = db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(stickers);
}
[HttpGet("{packId:guid}/content/{id:guid}")]
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
{
@@ -420,4 +436,4 @@ public class StickerController(
return NoContent();
}
}
}

View File

@@ -26,7 +26,7 @@ public class StickerService(
{
FileId = sticker.Image.Id,
Usage = StickerFileUsageIdentifier,
ResourceId = sticker.ResourceIdentifier
ResourceId = sticker.ResourceIdentifier
});
return sticker;
@@ -109,9 +109,25 @@ public class StickerService(
// If not in cache, fetch from the database
IQueryable<SnSticker> query = db.Stickers
.Include(e => e.Pack);
query = Guid.TryParse(identifier, out var guid)
? query.Where(e => e.Id == guid)
: query.Where(e => EF.Functions.ILike(e.Pack.Prefix + e.Slug, identifier));
var isV2 = identifier.Contains("+");
var identifierParts = identifier.Split('+');
if (identifierParts.Length < 2) isV2 = false;
if (isV2)
{
var packPart = identifierParts[0];
var stickerPart = identifierParts[1];
query = query.Where(e => EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
}
else
{
query = Guid.TryParse(identifier, out var guid)
? query.Where(e => e.Id == guid)
: query.Where(e => EF.Functions.ILike(e.Pack.Prefix + e.Slug, identifier));
}
var sticker = await query.FirstOrDefaultAsync();
@@ -128,4 +144,4 @@ public class StickerService(
await cache.RemoveAsync($"sticker:lookup:{sticker.Id}");
await cache.RemoveAsync($"sticker:lookup:{sticker.Pack.Prefix}{sticker.Slug}");
}
}
}

View File

@@ -20,8 +20,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Develop", "Dys
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Control", "DysonNetwork.Control\DysonNetwork.Control.csproj", "{7FFED190-51C7-4302-A8B5-96C839463458}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.ServiceDefaults", "DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj", "{877AAD96-C257-4305-9F1C-C9D9C9BEE615}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Gateway", "DysonNetwork.Gateway\DysonNetwork.Gateway.csproj", "{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}"
EndProject
Global
@@ -58,10 +56,6 @@ Global
{7FFED190-51C7-4302-A8B5-96C839463458}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7FFED190-51C7-4302-A8B5-96C839463458}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7FFED190-51C7-4302-A8B5-96C839463458}.Release|Any CPU.Build.0 = Release|Any CPU
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Debug|Any CPU.Build.0 = Debug|Any CPU
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Release|Any CPU.ActiveCfg = Release|Any CPU
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Release|Any CPU.Build.0 = Release|Any CPU
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -71,6 +71,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpUtility_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003F08_003Fdd41228e_003FHttpUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb55221b2bd14b31a20b0d8bdcc7ff457328_003F19_003F707d23be_003FIConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffd5f2a75d480e8c786b15cfa0ac11aa9bf445a667ad13d25dc9db61f2cb1b_003FIConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIDatabaseAsync_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F93b441a9e8201c5bdfa1b1fdc8061a77b86ccbb8566d7bae85036aba8c618f7_003FIDatabaseAsync_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEtcdClient_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F866376757aa64634b820c41d3553727886400_003Fbb_003F0fd3f8d7_003FIEtcdClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHtmlString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003Ff1_003F3a8957fa_003FIHtmlString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpForwarder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F29_003F7eee2eb9_003FIHttpForwarder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>

View File

@@ -0,0 +1,345 @@
# Gift Subscriptions API Documentation
## Overview
The Gift Subscriptions feature allows users to purchase subscription gifts that can be redeemed by other users, enabling social gifting and subscription sharing within the DysonNetwork platform.
If you use it through the gateway, the `/api` should be replaced with the `/id`
### Key Features
- **Purchase Gifts**: Users can buy subscriptions as gifts for specific recipients or as open gifts
- **Gift Codes**: Each gift has a unique redemption code
- **Flexible Redemption**: Open gifts can be redeemed by anyone, while targeted gifts are recipient-specific
- **Security**: Prevents duplicate subscriptions and enforces account level requirements
- **Integration**: Full integration with existing subscription, coupon, and pricing systems
- **Clean User Experience**: Unpaid gifts are hidden from users and automatically cleaned up
- **Automatic Maintenance**: Old unpaid gifts are removed after 24 hours
## API Endpoints
All endpoints are authenticated and require a valid user session. The base path for gift endpoints is `/api/gifts`.
### 1. List Sent Gifts
Retrieve gifts you have purchased.
```http
GET /api/gifts/sent?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 2. List Received Gifts
Retrieve gifts sent to you or redeemed by you (for open gifts).
```http
GET /api/gifts/received?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 3. Get Specific Gift
Retrieve details for a specific gift.
```http
GET /api/gifts/{giftId}
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift
**Response**: `SnWalletGift` object
### 4. Check Gift Code
Validate if a gift code can be redeemed by the current user.
```http
GET /api/gifts/check/{giftCode}
Authorization: Bearer <token>
```
**Response**:
```json
{
"gift_code": "ABCD1234EFGH",
"subscription_identifier": "basic",
"can_redeem": true,
"error": null,
"message": "Happy birthday!"
}
```
### 5. Purchase a Gift
Create and purchase a gift subscription.
```http
POST /api/gifts/purchase
Authorization: Bearer <token>
Content-Type: application/json
{
"subscription_identifier": "premium",
"recipient_id": "550e8400-e29b-41d4-a716-446655440000", // Optional: null for open gifts
"payment_method": "in_app_wallet",
"payment_details": {
"currency": "irl"
},
"message": "Enjoy your premium subscription!", // Optional
"coupon": "SAVE20", // Optional
"gift_duration_days": 30, // Optional: defaults to 30
"subscription_duration_days": 30 // Optional: defaults to 30
}
```
**Response**: `SnWalletGift` object
### 6. Redeem a Gift
Redeem a gift code to create a subscription for yourself.
```http
POST /api/gifts/redeem
Authorization: Bearer <token>
Content-Type: application/json
{
"gift_code": "ABCD1234EFGH"
}
```
**Response**:
```json
{
"gift": { ... },
"subscription": { ... }
}
```
### 7. Mark Gift as Sent
Mark a gift as sent (ready for redemption).
```http
POST /api/gifts/{giftId}/send
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to mark as sent
### 8. Cancel a Gift
Cancel a gift before it has been redeemed.
```http
POST /api/gifts/{giftId}/cancel
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to cancel
## Usage Examples
### Client Implementation
Here are examples showing how to integrate gift subscriptions into your client application.
#### Example 1: Purchase a Gift for a Specific User
```javascript
async function purchaseGiftForFriend(subscriptionId, friendId, message) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
recipient_id: friendId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: message
})
});
const gift = await response.json();
return gift.gift_code; // Share this code with the friend
}
```
#### Example 2: Create an Open Gift
```javascript
async function createOpenGift(subscriptionId) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: 'Redeem this anywhere!'
// No recipient_id makes it an open gift
})
});
const gift = await response.json();
// Mark as sent to make it redeemable
await markGiftAsSent(gift.id);
return gift;
}
```
#### Example 3: Redeem a Gift Code
```javascript
async function redeemGiftCode(giftCode) {
// First, check if the gift can be redeemed
const checkResponse = await fetch(`/api/gifts/check/${giftCode}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const checkResult = await checkResponse.json();
if (!checkResult.canRedeem) {
throw new Error(checkResult.error);
}
// If valid, redeem it
const redeemResponse = await fetch('/api/gifts/redeem', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
gift_code: giftCode
})
});
const result = await redeemResponse.json();
return result.subscription; // The newly created subscription
}
```
#### Example 4: Display User's Gift History
```javascript
async function getGiftHistory() {
// Get gifts I sent
const sentResponse = await fetch('/api/gifts/sent', {
headers: { 'Authorization': `Bearer ${token}` }
});
const sentGifts = await sentResponse.json();
// Get gifts I received
const receivedResponse = await fetch('/api/gifts/received', {
headers: { 'Authorization': `Bearer ${token}` }
});
const receivedGifts = await receivedResponse.json();
return { sent: sentGifts, received: receivedGifts };
}
```
## Gift Status Lifecycle
Gifts follow this status lifecycle:
1. **Created**: Initially purchased, can be cancelled or marked as sent
- **Note**: Gifts in "Created" status are not visible to users and are automatically cleaned up after 24 hours if unpaid
2. **Sent**: Made available for redemption, can be cancelled
3. **Redeemed**: Successfully redeemed, creates a subscription
4. **Cancelled**: Permanently cancelled, refund may be processed
5. **Expired**: Expired without redemption
## Automatic Maintenance
The system includes automatic cleanup to maintain data integrity:
- **Unpaid Gift Cleanup**: Gifts that remain in "Created" status (unpaid) for more than 24 hours are automatically removed from the database
- **User Visibility**: Only gifts that have been successfully paid and sent are visible in user gift lists
- **Background Processing**: Cleanup runs hourly via scheduled jobs
This ensures a clean user experience while preventing accumulation of abandoned gift purchases.
## Validation Rules
### Purchase Validation
- Subscription must exist and be valid
- If coupon provided, it must be valid and applicable
- Recipient account must exist (if specified)
- User must meet level requirements for the subscription
### Redemption Validation
- Gift code must exist
- Gift must be in "Sent" status
- Gift must not be expired
- User must meet level requirements
- User must not already have an active subscription of the same type
- For targeted gifts, user must be the specified recipient
## Pricing & Payments
Gifts use the same pricing system as regular subscriptions:
- Base price from subscription template
- Coupon discounts applied
- Currency conversion as needed
- Payment processing through existing payment methods
## Notification Events
The system sends push notifications for:
- **gifts.redeemed**: When someone redeems your gift
- **gifts.claimed**: When the recipient redeems your targeted gift
Notifications include gift and subscription details for rich UI updates.
## Error Handling
Common error responses:
- `400 Bad Request`: Invalid parameters, validation failures
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Gift or subscription not found
- `409 Conflict`: Business logic violations (duplicate subscriptions, etc.)
## Integration Notes
### Database Schema
The feature adds a `wallet_gifts` table with relationships to:
- `accounts` (gifter, recipient, redeemer)
- `wallet_subscriptions` (created subscription)
- `wallet_coupons` (applied discounts)
### Backwards Compatibility
- No changes to existing subscription endpoints
- New gift-related endpoints are additive
- Existing payment flows remain unchanged
### Performance Considerations
- Gift codes are indexed for fast lookups
- Status filters optimize database queries
- Caching integrated with existing subscription caching
## Support
For implementation questions or issues, refer to the DysonNetwork API documentation or contact the development team.

429
README_WALLET_FUNDS.md Normal file
View File

@@ -0,0 +1,429 @@
# Wallet Funds (Red Packet) System
## Overview
The Wallet Funds system implements red packet functionality for the DysonNetwork platform, allowing users to create funds that can be split among multiple recipients. Recipients must explicitly claim their portion, and unclaimed funds are automatically refunded after expiration.
## Features
- **Red Packet Creation**: Users can create funds with total amounts to be distributed
- **Split Types**: Even distribution or random (lucky draw) splitting
- **Claim System**: Recipients must actively claim their portion
- **Expiration**: Automatic refund of unclaimed funds after 24 hours
- **Multi-Recipient**: Support for distributing to multiple users simultaneously
- **Audit Trail**: Full transaction history and status tracking
## Architecture
### Models
#### SnWalletFund
```csharp
public class SnWalletFund : ModelBase
{
public Guid Id { get; set; }
public string Currency { get; set; }
public decimal TotalAmount { get; set; }
public FundSplitType SplitType { get; set; }
public FundStatus Status { get; set; }
public string? Message { get; set; }
public Guid CreatorAccountId { get; set; }
public SnAccount CreatorAccount { get; set; }
public ICollection<SnWalletFundRecipient> Recipients { get; set; }
public Instant ExpiredAt { get; set; }
}
```
#### SnWalletFundRecipient
```csharp
public class SnWalletFundRecipient : ModelBase
{
public Guid Id { get; set; }
public Guid FundId { get; set; }
public SnWalletFund Fund { get; set; }
public Guid RecipientAccountId { get; set; }
public SnAccount RecipientAccount { get; set; }
public decimal Amount { get; set; }
public bool IsReceived { get; set; }
public Instant? ReceivedAt { get; set; }
}
```
### Enums
#### FundSplitType
- `Even`: Equal distribution among all recipients
- `Random`: Random amounts that sum to total
#### FundStatus
- `Created`: Fund created, waiting for claims
- `PartiallyReceived`: Some recipients have claimed
- `FullyReceived`: All recipients have claimed
- `Expired`: Fund expired, unclaimed amounts refunded
- `Refunded`: Fund was refunded (legacy status)
## API Endpoints
### Create Fund
**POST** `/api/wallets/funds`
Creates a new fund (red packet) for distribution among recipients.
**Request Body:**
```json
{
"recipientAccountIds": ["uuid1", "uuid2", "uuid3"],
"currency": "points",
"totalAmount": 100.00,
"splitType": "Even",
"message": "Happy Birthday! 🎉",
"expirationHours": 48
}
```
**Response:** `SnWalletFund` object
**Authorization:** Required (authenticated user becomes the creator)
---
### Get Funds
**GET** `/api/wallets/funds`
Retrieves funds that the authenticated user is involved in (as creator or recipient).
**Query Parameters:**
- `offset` (int, optional): Pagination offset (default: 0)
- `take` (int, optional): Number of items to return (default: 20)
- `status` (FundStatus, optional): Filter by fund status
**Response:** Array of `SnWalletFund` objects with `X-Total` header
**Authorization:** Required
---
### Get Fund
**GET** `/api/wallets/funds/{id}`
Retrieves details of a specific fund.
**Path Parameters:**
- `id` (Guid): Fund ID
**Response:** `SnWalletFund` object with recipients
**Authorization:** Required (user must be creator or recipient)
---
### Receive Fund
**POST** `/api/wallets/funds/{id}/receive`
Claims the authenticated user's portion of a fund.
**Path Parameters:**
- `id` (Guid): Fund ID
**Response:** `SnWalletTransaction` object
**Authorization:** Required (user must be a recipient)
## Service Methods
### Creating a Fund
```csharp
// Service method
public async Task<SnWalletFund> CreateFundAsync(
Guid creatorAccountId,
List<Guid> recipientAccountIds,
string currency,
decimal totalAmount,
FundSplitType splitType,
string? message = null,
Duration? expiration = null)
```
**Parameters:**
- `creatorAccountId`: Account ID of the fund creator
- `recipientAccountIds`: List of recipient account IDs
- `currency`: Currency type (e.g., "points", "golds")
- `totalAmount`: Total amount to distribute
- `splitType`: How to split the amount (Even/Random)
- `message`: Optional message for the fund
- `expiration`: Optional expiration duration (default: 24 hours)
**Example:**
```csharp
var fund = await paymentService.CreateFundAsync(
creatorId: userId,
recipientAccountIds: new List<Guid> { friend1Id, friend2Id, friend3Id },
currency: "points",
totalAmount: 100.00m,
splitType: FundSplitType.Even,
message: "Happy New Year!",
expiration: Duration.FromHours(48) // Optional: 48 hours instead of default 24
);
```
### Claiming a Fund
```csharp
// Service method
public async Task<SnWalletTransaction> ReceiveFundAsync(
Guid recipientAccountId,
Guid fundId)
```
**Parameters:**
- `recipientAccountId`: Account ID of the recipient claiming the fund
- `fundId`: ID of the fund to claim from
**Example:**
```csharp
var transaction = await paymentService.ReceiveFundAsync(
recipientAccountId: myAccountId,
fundId: fundId
);
```
## Split Logic
### Even Split
Distributes the total amount equally among all recipients, handling decimal precision properly:
```csharp
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
{
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100;
var remainder = totalAmount - (baseAmount * recipientCount);
var amounts = new List<decimal>();
for (int i = 0; i < recipientCount; i++)
{
var amount = baseAmount;
if (i < remainder * 100)
amount += 0.01m; // Distribute remainder as 0.01 increments
amounts.Add(amount);
}
return amounts;
}
```
**Example:** 100.00 split among 3 recipients = [33.34, 33.33, 33.33]
### Random Split
Generates random amounts that sum exactly to the total:
```csharp
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
{
var random = new Random();
var amounts = new List<decimal>();
decimal remaining = totalAmount;
for (int i = 0; i < recipientCount - 1; i++)
{
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
var minAmount = 0.01m;
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
amounts.Add(amount);
remaining -= amount;
}
amounts.Add(Math.Round(remaining, 2)); // Last recipient gets remainder
return amounts;
}
```
**Example:** 100.00 split randomly among 3 recipients = [45.67, 23.45, 30.88]
## Expiration and Refunds
### Automatic Processing
Funds are processed hourly by the `FundExpirationJob`:
```csharp
public async Task ProcessExpiredFundsAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var expiredFunds = await db.WalletFunds
.Include(f => f.Recipients)
.Where(f => f.Status == FundStatus.Created || f.Status == FundStatus.PartiallyReceived)
.Where(f => f.ExpiredAt < now)
.ToListAsync();
foreach (var fund in expiredFunds)
{
var unclaimedAmount = fund.Recipients
.Where(r => !r.IsReceived)
.Sum(r => r.Amount);
if (unclaimedAmount > 0)
{
// Refund to creator
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
if (creatorWallet != null)
{
await CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: creatorWallet.Id,
currency: fund.Currency,
amount: unclaimedAmount,
remarks: $"Refund for expired fund {fund.Id}",
type: TransactionType.System,
silent: true
);
}
}
fund.Status = FundStatus.Expired;
}
await db.SaveChangesAsync();
}
```
### Expiration Rules
- Default expiration: 24 hours from creation
- Custom expiration can be set when creating the fund
- Only funds with status `Created` or `PartiallyReceived` are processed
- Unclaimed amounts are refunded to the creator
- Fund status changes to `Expired`
## Security & Validation
### Creation Validation
- Creator must have sufficient funds
- All recipient accounts must exist and have wallets
- At least one recipient required
- Total amount must be positive
- Creator cannot be a recipient (self-transfer not allowed)
### Claim Validation
- Fund must exist and not be expired/refunded
- Recipient must be in the recipient list
- Recipient can only claim once
- Recipient must have a valid wallet
### Error Handling
- `ArgumentException`: Invalid parameters
- `InvalidOperationException`: Business logic violations
- All errors provide descriptive messages
## Database Schema
### wallet_funds
```sql
CREATE TABLE wallet_funds (
id UUID PRIMARY KEY,
currency VARCHAR(128) NOT NULL,
total_amount DECIMAL NOT NULL,
split_type INTEGER NOT NULL,
status INTEGER NOT NULL,
message TEXT,
creator_account_id UUID NOT NULL,
expired_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ
);
```
### wallet_fund_recipients
```sql
CREATE TABLE wallet_fund_recipients (
id UUID PRIMARY KEY,
fund_id UUID NOT NULL REFERENCES wallet_funds(id),
recipient_account_id UUID NOT NULL,
amount DECIMAL NOT NULL,
is_received BOOLEAN NOT NULL DEFAULT FALSE,
received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ
);
```
## Integration Points
### Wallet System
- Funds are deducted from creator's wallet pocket immediately upon creation
- Individual claims credit recipient's wallet pocket
- Refunds credit creator's wallet pocket
- All operations create audit transactions
### Notification System
- Integrates with existing push notification system
- Notifications sent for fund creation and claims
- Uses localized messages for different languages
### Scheduled Jobs
- `FundExpirationJob` runs every hour
- Processes expired funds automatically
- Handles refunds and status updates
## Usage Examples
### Red Packet for Group Event
```csharp
// Create a red packet for 5 friends totaling 500 points
var fund = await paymentService.CreateFundAsync(
creatorId,
friendIds, // List of 5 friend account IDs
"points",
500.00m,
FundSplitType.Random, // Lucky draw
"Happy Birthday! 🎉"
);
```
### Equal Split Bonus Distribution
```csharp
// Distribute bonus equally among team members
var fund = await paymentService.CreateFundAsync(
managerId,
teamMemberIds,
"golds",
1000.00m,
FundSplitType.Even,
"Monthly performance bonus"
);
```
### Claiming a Fund
```csharp
// User claims their portion
try
{
var transaction = await paymentService.ReceiveFundAsync(userId, fundId);
// Success - funds credited to user's wallet
}
catch (InvalidOperationException ex)
{
// Handle error (already claimed, expired, not recipient, etc.)
}
```
## Monitoring & Maintenance
### Key Metrics
- Total funds created per period
- Claim rate (claimed vs expired)
- Average expiration time
- Popular split types
### Cleanup
- Soft-deleted records are cleaned up by `AppDatabaseRecyclingJob`
- Expired funds are processed by `FundExpirationJob`
- No manual intervention required for normal operation
## Future Enhancements
- **Fund Templates**: Pre-configured fund types
- **Recurring Funds**: Scheduled fund distributions
- **Fund Analytics**: Detailed usage statistics
- **Fund Categories**: Tagging and categorization
- **Bulk Operations**: Create funds for multiple groups
- **Fund Forwarding**: Allow recipients to forward unclaimed portions

2237
debug.txt

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More