Compare commits
67 Commits
fa24f14c05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
7385caff9a
|
|||
|
15954dbfe2
|
|||
|
4ba6206c9d
|
|||
|
266b9e36e2
|
|||
|
e6aa61b03b
|
|||
|
0c09ef25ec
|
|||
|
dd5929c691
|
|||
|
cf87fdfb49
|
|||
|
ff03584518
|
|||
|
d6c37784e1
|
|||
|
46ebd92dc1
|
|||
|
7f8521bb40
|
|||
|
f01226d91a
|
|||
|
6cb6dee6be
|
|||
|
0e9caf67ff
|
|||
|
ca70bb5487
|
|||
|
59ed135f20
|
|||
|
6077f91529
|
|||
|
5c485bb1c3
|
|||
|
27d979d77b
|
|||
|
15687a0c32
|
|||
|
37ea882ef7
|
|||
|
e624c2bb3e
|
|||
|
9631cd3edd
|
|||
|
f4a659fce5
|
|||
|
1ded811b36
|
|||
|
32977d9580
|
|||
|
aaf29e7228
|
|||
|
658ef3bddf
|
|||
|
fc0bc936ce
|
|||
|
3850ae6a8e
|
|||
|
21c99567b4
|
|||
|
1315c7f4d4
|
|||
|
630a532d98
|
|||
|
b9bb180113
|
|||
|
04d74d0d70
|
|||
|
6a8a0ed491
|
|||
|
0f835845bf
|
|||
|
c5d8a8d07f
|
|||
|
95e2ba1136
|
|||
|
1176fde8b4
|
|||
|
e634968e00
|
|||
|
282a1dbddc
|
|||
|
c64adace24
|
|||
|
8ac0b28c66
|
|||
|
8f71d7f9e5
|
|||
|
c435e63917
|
|||
|
243159e4cc
|
|||
|
42dad7095a
|
|||
|
d1efcdede8
|
|||
|
47680475b3
|
|||
|
6632d43f32
|
|||
|
29c4dcd71c
|
|||
|
e7aa887715
|
|||
|
0f05633996
|
|||
|
966af08a33
|
|||
|
b25b90a074
|
|||
|
dcbefeaaab
|
|||
|
eb83a0392a
|
|||
|
85fefcf724
|
|||
|
d17c26a228
|
|||
|
2e5ef8ff94
|
|||
|
7a5f410e36
|
|||
|
0b4e8a9777
|
|||
|
30fd912281
|
|||
|
5bf58f0194
|
|||
|
8e3e3f09df
|
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
613
API_WALLET_FUNDS.md
Normal file
613
API_WALLET_FUNDS.md
Normal 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
|
||||||
@@ -7,25 +7,17 @@ var isDev = builder.Environment.IsDevelopment();
|
|||||||
var cache = builder.AddRedis("cache");
|
var cache = builder.AddRedis("cache");
|
||||||
var queue = builder.AddNats("queue").WithJetStream();
|
var queue = builder.AddNats("queue").WithJetStream();
|
||||||
|
|
||||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
|
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
|
||||||
.WithReference(queue);
|
|
||||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||||
.WithReference(cache)
|
|
||||||
.WithReference(queue)
|
|
||||||
.WithReference(ringService);
|
.WithReference(ringService);
|
||||||
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
||||||
.WithReference(cache)
|
|
||||||
.WithReference(queue)
|
|
||||||
.WithReference(passService)
|
.WithReference(passService)
|
||||||
.WithReference(ringService);
|
.WithReference(ringService);
|
||||||
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
||||||
.WithReference(cache)
|
|
||||||
.WithReference(queue)
|
|
||||||
.WithReference(passService)
|
.WithReference(passService)
|
||||||
.WithReference(ringService)
|
.WithReference(ringService)
|
||||||
.WithReference(driveService);
|
.WithReference(driveService);
|
||||||
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
||||||
.WithReference(cache)
|
|
||||||
.WithReference(passService)
|
.WithReference(passService)
|
||||||
.WithReference(ringService)
|
.WithReference(ringService)
|
||||||
.WithReference(sphereService);
|
.WithReference(sphereService);
|
||||||
@@ -38,6 +30,9 @@ List<IResourceBuilder<ProjectResource>> services =
|
|||||||
for (var idx = 0; idx < services.Count; idx++)
|
for (var idx = 0; idx < services.Count; idx++)
|
||||||
{
|
{
|
||||||
var service = services[idx];
|
var service = services[idx];
|
||||||
|
|
||||||
|
service.WithReference(cache).WithReference(queue);
|
||||||
|
|
||||||
var grpcPort = 7002 + idx;
|
var grpcPort = 7002 + idx;
|
||||||
|
|
||||||
if (isDev)
|
if (isDev)
|
||||||
@@ -60,14 +55,12 @@ for (var idx = 0; idx < services.Count; idx++)
|
|||||||
ringService.WithReference(passService);
|
ringService.WithReference(passService);
|
||||||
|
|
||||||
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
|
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
|
||||||
.WithReference(ringService)
|
|
||||||
.WithReference(passService)
|
|
||||||
.WithReference(driveService)
|
|
||||||
.WithReference(sphereService)
|
|
||||||
.WithReference(developService)
|
|
||||||
.WithEnvironment("HTTP_PORTS", "5001")
|
.WithEnvironment("HTTP_PORTS", "5001")
|
||||||
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
|
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
|
||||||
|
|
||||||
|
foreach (var service in services)
|
||||||
|
gateway.WithReference(service);
|
||||||
|
|
||||||
builder.AddDockerComposeEnvironment("docker-compose");
|
builder.AddDockerComposeEnvironment("docker-compose");
|
||||||
|
|
||||||
builder.Build().Run();
|
builder.Build().Run();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
|
||||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0"/>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
@@ -10,14 +8,12 @@
|
|||||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
|
<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.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.0" />
|
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" />
|
||||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" />
|
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||||
@@ -26,5 +22,4 @@
|
|||||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"DOTNET_ENVIRONMENT": "Development",
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
"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": {
|
"http": {
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"DOTNET_ENVIRONMENT": "Development",
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="NodaTime" Version="3.2.2"/>
|
<PackageReference Include="NodaTime" Version="3.2.2"/>
|
||||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
||||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ namespace DysonNetwork.Develop.Identity;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class BotAccountController(
|
public class BotAccountController(
|
||||||
BotAccountService botService,
|
BotAccountService botService,
|
||||||
DeveloperService developerService,
|
DeveloperService ds,
|
||||||
DevProjectService projectService,
|
DevProjectService projectService,
|
||||||
ILogger<BotAccountController> logger,
|
ILogger<BotAccountController> logger,
|
||||||
AccountClientHelper accounts,
|
RemoteAccountService remoteAccounts,
|
||||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
@@ -50,9 +50,9 @@ public class BotAccountController(
|
|||||||
]
|
]
|
||||||
public string Name { get; set; } = string.Empty;
|
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";
|
[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;
|
[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; }
|
[MaxLength(128)] public string? Language { get; set; }
|
||||||
|
|
||||||
@@ -83,11 +83,11 @@ public class BotAccountController(
|
|||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
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))
|
Shared.Proto.PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
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)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
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))
|
Shared.Proto.PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
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)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
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))
|
Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to create a bot");
|
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)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
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))
|
Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ public class BotAccountController(
|
|||||||
if (bot is null || bot.ProjectId != projectId)
|
if (bot is null || bot.ProjectId != projectId)
|
||||||
return NotFound("Bot not found");
|
return NotFound("Bot not found");
|
||||||
|
|
||||||
var botAccount = await accounts.GetBotAccount(bot.Id);
|
var botAccount = await remoteAccounts.GetBotAccount(bot.Id);
|
||||||
|
|
||||||
if (request.Name is not null) botAccount.Name = request.Name;
|
if (request.Name is not null) botAccount.Name = request.Name;
|
||||||
if (request.Nick is not null) botAccount.Nick = request.Nick;
|
if (request.Nick is not null) botAccount.Nick = request.Nick;
|
||||||
@@ -267,11 +267,11 @@ public class BotAccountController(
|
|||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
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))
|
Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
||||||
|
|
||||||
@@ -443,10 +443,10 @@ public class BotAccountController(
|
|||||||
Account currentUser,
|
Account currentUser,
|
||||||
Shared.Proto.PublisherMemberRole requiredRole)
|
Shared.Proto.PublisherMemberRole requiredRole)
|
||||||
{
|
{
|
||||||
var developer = await developerService.GetDeveloperByName(pubName);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer == null) return (null, null, null);
|
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);
|
return (null, null, null);
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace DysonNetwork.Develop.Identity;
|
|||||||
public class BotAccountService(
|
public class BotAccountService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
|
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
|
||||||
AccountClientHelper accounts
|
RemoteAccountService remoteAccounts
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
|
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
|
||||||
@@ -20,7 +20,7 @@ public class BotAccountService(
|
|||||||
.FirstOrDefaultAsync(b => b.Id == id);
|
.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
|
return await db.BotAccounts
|
||||||
.Where(b => b.ProjectId == projectId)
|
.Where(b => b.ProjectId == projectId)
|
||||||
@@ -155,11 +155,10 @@ public class BotAccountService(
|
|||||||
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
|
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
|
||||||
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
|
(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 automatedIds = bots.Select(b => b.Id).ToList();
|
||||||
var data = await accounts.GetBotAccountBatch(automatedIds);
|
var data = await remoteAccounts.GetBotAccountBatch(automatedIds);
|
||||||
|
|
||||||
foreach (var bot in bots)
|
foreach (var bot in bots)
|
||||||
{
|
{
|
||||||
@@ -168,6 +167,6 @@ public class BotAccountService(
|
|||||||
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return bots as List<SnBotAccount> ?? [];
|
return bots;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,6 @@ using (var scope = app.Services.CreateScope())
|
|||||||
|
|
||||||
app.ConfigureAppMiddleware(builder.Configuration);
|
app.ConfigureAppMiddleware(builder.Configuration);
|
||||||
|
|
||||||
app.UseSwaggerManifest();
|
app.UseSwaggerManifest("DysonNetwork.Develop");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
|
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
|
||||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" 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.AspNetCore" Version="1.13.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
|
||||||
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
|
|||||||
// Add application services
|
// Add application services
|
||||||
|
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddDysonAuth();
|
builder.Services.AddDysonAuth();
|
||||||
builder.Services.AddAccountService();
|
builder.Services.AddAccountService();
|
||||||
@@ -49,6 +48,6 @@ app.ConfigureAppMiddleware(tusDiskStore);
|
|||||||
// Configure gRPC
|
// Configure gRPC
|
||||||
app.ConfigureGrpcServices();
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
app.UseSwaggerManifest();
|
app.UseSwaggerManifest("DysonNetwork.Drive");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class BroadcastEventHandler(
|
|||||||
|
|
||||||
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
|
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
|
||||||
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
|
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 accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
|
||||||
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
|
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
|
||||||
@@ -75,8 +75,8 @@ public class BroadcastEventHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload?.FileId);
|
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
|
||||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,19 +43,6 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
|
||||||
{
|
|
||||||
opts.Window = TimeSpan.FromMinutes(1);
|
|
||||||
opts.PermitLimit = 120;
|
|
||||||
opts.QueueLimit = 2;
|
|
||||||
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
|
|||||||
@@ -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)
|
if (!file.PoolId.HasValue)
|
||||||
{
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
|
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
|
||||||
}
|
|
||||||
|
|
||||||
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
||||||
if (pool is null)
|
if (pool is null)
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Yarp.ReverseProxy.Configuration;
|
using Yarp.ReverseProxy.Configuration;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -17,19 +17,43 @@ builder.Services.AddCors(options =>
|
|||||||
policy.SetIsOriginAllowed(origin => true)
|
policy.SetIsOriginAllowed(origin => true)
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowCredentials();
|
.AllowCredentials()
|
||||||
|
.WithExposedHeaders("X-Total");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
options.AddFixedWindowLimiter("fixed", limiterOptions =>
|
options.AddPolicy("fixed", context =>
|
||||||
{
|
{
|
||||||
limiterOptions.PermitLimit = 120;
|
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
|
||||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
return RateLimitPartition.GetFixedWindowLimiter(
|
||||||
limiterOptions.QueueLimit = 0;
|
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" };
|
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" };
|
||||||
@@ -66,7 +90,6 @@ var apiRoutes = serviceNames.Select(serviceName =>
|
|||||||
{
|
{
|
||||||
var apiPath = serviceName switch
|
var apiPath = serviceName switch
|
||||||
{
|
{
|
||||||
"pass" => "/id",
|
|
||||||
_ => $"/{serviceName}"
|
_ => $"/{serviceName}"
|
||||||
};
|
};
|
||||||
return new RouteConfig
|
return new RouteConfig
|
||||||
@@ -99,6 +122,20 @@ var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
|
|||||||
var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
||||||
{
|
{
|
||||||
ClusterId = serviceName,
|
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>
|
Destinations = new Dictionary<string, DestinationConfig>
|
||||||
{
|
{
|
||||||
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
|
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
|
||||||
@@ -106,16 +143,26 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
|||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddReverseProxy()
|
.AddReverseProxy()
|
||||||
.LoadFromMemory(routes, clusters)
|
.LoadFromMemory(routes, clusters)
|
||||||
.AddServiceDiscoveryDestinationResolver();
|
.AddServiceDiscoveryDestinationResolver();
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors();
|
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.All
|
||||||
|
};
|
||||||
|
forwardedHeadersOptions.KnownNetworks.Clear();
|
||||||
|
forwardedHeadersOptions.KnownProxies.Clear();
|
||||||
|
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||||
|
|
||||||
app.UseRateLimiter();
|
app.UseCors();
|
||||||
|
|
||||||
app.MapReverseProxy().RequireRateLimiting("fixed");
|
app.MapReverseProxy().RequireRateLimiting("fixed");
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -5,5 +5,9 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"SiteUrl": "http://localhost:3000",
|
||||||
|
"Client": {
|
||||||
|
"SomeSetting": "SomeValue"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ public class AccountCurrentController(
|
|||||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||||
[MaxLength(1024)] public string? Location { get; set; }
|
[MaxLength(1024)] public string? Location { get; set; }
|
||||||
[MaxLength(4096)] public string? Bio { get; set; }
|
[MaxLength(4096)] public string? Bio { get; set; }
|
||||||
|
public Shared.Models.UsernameColor? UsernameColor { get; set; }
|
||||||
public Instant? Birthday { get; set; }
|
public Instant? Birthday { get; set; }
|
||||||
public List<ProfileLink>? Links { 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.Location is not null) profile.Location = request.Location;
|
||||||
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
||||||
if (request.Links is not null) profile.Links = request.Links;
|
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)
|
if (request.PictureId is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ public class AccountServiceGrpc(
|
|||||||
AccountEventService accountEvents,
|
AccountEventService accountEvents,
|
||||||
RelationshipService relationships,
|
RelationshipService relationships,
|
||||||
SubscriptionService subscriptions,
|
SubscriptionService subscriptions,
|
||||||
IClock clock,
|
|
||||||
ILogger<AccountServiceGrpc> logger
|
ILogger<AccountServiceGrpc> logger
|
||||||
)
|
)
|
||||||
: Shared.Proto.AccountService.AccountServiceBase
|
: Shared.Proto.AccountService.AccountServiceBase
|
||||||
{
|
{
|
||||||
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
|
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
|
||||||
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
||||||
|
|
||||||
private readonly ILogger<AccountServiceGrpc>
|
private readonly ILogger<AccountServiceGrpc>
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
@@ -160,6 +158,26 @@ public class AccountServiceGrpc(
|
|||||||
return response;
|
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,
|
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
@@ -246,7 +264,7 @@ public class AccountServiceGrpc(
|
|||||||
|
|
||||||
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
|
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var hasRelationship = false;
|
bool hasRelationship;
|
||||||
if (!request.HasStatus)
|
if (!request.HasStatus)
|
||||||
hasRelationship = await relationships.HasExistingRelationship(
|
hasRelationship = await relationships.HasExistingRelationship(
|
||||||
Guid.Parse(request.AccountId),
|
Guid.Parse(request.AccountId),
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ public class AppDatabase(
|
|||||||
public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
|
public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
|
||||||
public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
|
public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<SnRealm> Realms { get; set; } = null!;
|
||||||
|
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<SnWallet> Wallets { get; set; } = null!;
|
public DbSet<SnWallet> Wallets { get; set; } = null!;
|
||||||
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
|
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
|
||||||
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
|
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
|
||||||
public DbSet<SnWalletTransaction> PaymentTransactions { 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<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
|
||||||
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
|
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
|
||||||
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
|
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
|
||||||
@@ -52,6 +57,9 @@ public class AppDatabase(
|
|||||||
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
|
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
|
||||||
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
|
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<SnLottery> Lotteries { get; set; } = null!;
|
||||||
|
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql(
|
optionsBuilder.UseNpgsql(
|
||||||
@@ -126,6 +134,14 @@ public class AppDatabase(
|
|||||||
.WithMany(a => a.IncomingRelationships)
|
.WithMany(a => a.IncomingRelationships)
|
||||||
.HasForeignKey(r => r.RelatedId);
|
.HasForeignKey(r => r.RelatedId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SnRealmMember>()
|
||||||
|
.HasKey(pm => new { pm.RealmId, pm.AccountId });
|
||||||
|
modelBuilder.Entity<SnRealmMember>()
|
||||||
|
.HasOne(pm => pm.Realm)
|
||||||
|
.WithMany(p => p.Members)
|
||||||
|
.HasForeignKey(pm => pm.RealmId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -343,15 +343,15 @@ public class OidcProviderController(
|
|||||||
{
|
{
|
||||||
issuer,
|
issuer,
|
||||||
authorization_endpoint = $"{siteUrl}/auth/authorize",
|
authorization_endpoint = $"{siteUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/id/auth/open/token",
|
token_endpoint = $"{baseUrl}/pass/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/id/auth/open/userinfo",
|
userinfo_endpoint = $"{baseUrl}/pass/auth/open/userinfo",
|
||||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
scopes_supported = new[] { "openid", "profile", "email" },
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
response_types_supported = new[]
|
response_types_supported = new[]
|
||||||
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||||
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
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" },
|
subject_types_supported = new[] { "public" },
|
||||||
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||||
code_challenge_methods_supported = new[] { "S256" },
|
code_challenge_methods_supported = new[] { "S256" },
|
||||||
|
|||||||
@@ -200,11 +200,13 @@ public class OidcProviderService(
|
|||||||
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
|
||||||
|
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(claims),
|
Subject = new ClaimsIdentity(claims),
|
||||||
Issuer = _options.IssuerUri,
|
Issuer = _options.IssuerUri,
|
||||||
Audience = client.Id.ToString(),
|
Audience = client.Slug.ToString(),
|
||||||
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
|
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
|
||||||
NotBefore = now.ToDateTimeUtc(),
|
NotBefore = now.ToDateTimeUtc(),
|
||||||
SigningCredentials = new SigningCredentials(
|
SigningCredentials = new SigningCredentials(
|
||||||
@@ -314,6 +316,7 @@ public class OidcProviderService(
|
|||||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
ClaimValueTypes.Integer64),
|
ClaimValueTypes.Integer64),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Azp, client.Slug),
|
||||||
]),
|
]),
|
||||||
Expires = expiresAt.ToDateTimeUtc(),
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
Issuer = _options.IssuerUri,
|
Issuer = _options.IssuerUri,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public class ConnectionController(
|
|||||||
IEnumerable<OidcService> oidcServices,
|
IEnumerable<OidcService> oidcServices,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
AuthService auth,
|
AuthService auth,
|
||||||
ICacheService cache
|
ICacheService cache,
|
||||||
|
IConfiguration configuration
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string StateCachePrefix = "oidc-state:";
|
private const string StateCachePrefix = "oidc-state:";
|
||||||
@@ -128,7 +129,7 @@ public class ConnectionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[Route("/auth/callback/{provider}")]
|
[Route("/api/auth/callback/{provider}")]
|
||||||
[HttpGet, HttpPost]
|
[HttpGet, HttpPost]
|
||||||
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
||||||
{
|
{
|
||||||
@@ -277,7 +278,9 @@ public class ConnectionController(
|
|||||||
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
|
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
|
||||||
await cache.RemoveAsync(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(
|
private async Task<IActionResult> HandleLoginOrRegistration(
|
||||||
@@ -341,7 +344,10 @@ public class ConnectionController(
|
|||||||
|
|
||||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
var loginToken = auth.CreateToken(loginSession);
|
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)
|
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public abstract class OidcService(
|
|||||||
{
|
{
|
||||||
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
||||||
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
||||||
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
RedirectUri = Configuration["SiteUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" 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.AspNetCore" Version="1.13.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.0"/>
|
<PackageReference Include="Otp.NET" Version="1.4.0"/>
|
||||||
@@ -44,12 +44,11 @@
|
|||||||
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
|
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
|
||||||
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
|
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
115
DysonNetwork.Pass/Lotteries/LotteryController.cs
Normal file
115
DysonNetwork.Pass/Lotteries/LotteryController.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Lotteries;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/lotteries")]
|
||||||
|
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase
|
||||||
|
{
|
||||||
|
public class CreateLotteryRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public List<int> RegionOneNumbers { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Range(0, 99)]
|
||||||
|
public int RegionTwoNumber { get; set; }
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int Multiplier { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnWalletOrder>> CreateLottery([FromBody] CreateLotteryRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var order = await lotteryService.CreateLotteryOrderAsync(
|
||||||
|
accountId: currentUser.Id,
|
||||||
|
region1: request.RegionOneNumbers,
|
||||||
|
region2: request.RegionTwoNumber,
|
||||||
|
multiplier: request.Multiplier);
|
||||||
|
|
||||||
|
return Ok(order);
|
||||||
|
}
|
||||||
|
catch (ArgumentException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<SnLottery>>> GetLotteries(
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int limit = 20)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var lotteries = await lotteryService.GetUserTicketsAsync(currentUser.Id, offset, limit);
|
||||||
|
var total = await lotteryService.GetUserTicketCountAsync(currentUser.Id);
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = total.ToString();
|
||||||
|
|
||||||
|
return Ok(lotteries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnLottery>> GetLottery(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var lottery = await lotteryService.GetTicketAsync(id);
|
||||||
|
if (lottery == null || lottery.AccountId != currentUser.Id)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(lottery);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("draw")]
|
||||||
|
[Authorize]
|
||||||
|
[RequiredPermission("maintenance", "lotteries.draw.perform")]
|
||||||
|
public async Task<IActionResult> PerformLotteryDraw()
|
||||||
|
{
|
||||||
|
await lotteryService.PerformDailyDrawAsync();
|
||||||
|
return Ok("Lottery draw performed successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("records")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<SnLotteryRecord>>> GetLotteryRecords(
|
||||||
|
[FromQuery] Instant? startDate = null,
|
||||||
|
[FromQuery] Instant? endDate = null,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int limit = 20)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var query = db.LotteryRecords.AsQueryable();
|
||||||
|
|
||||||
|
if (startDate.HasValue)
|
||||||
|
query = query.Where(r => r.DrawDate >= startDate.Value);
|
||||||
|
if (endDate.HasValue)
|
||||||
|
query = query.Where(r => r.DrawDate <= endDate.Value);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
Response.Headers["X-Total"] = total.ToString();
|
||||||
|
|
||||||
|
var records = await query
|
||||||
|
.OrderByDescending(r => r.DrawDate)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(records);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
DysonNetwork.Pass/Lotteries/LotteryDrawJob.cs
Normal file
21
DysonNetwork.Pass/Lotteries/LotteryDrawJob.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Lotteries;
|
||||||
|
|
||||||
|
public class LotteryDrawJob(LotteryService lotteryService, ILogger<LotteryDrawJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Starting daily lottery draw...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await lotteryService.PerformDailyDrawAsync();
|
||||||
|
logger.LogInformation("Daily lottery draw completed successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error occurred during daily lottery draw.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
DysonNetwork.Pass/Lotteries/LotteryService.cs
Normal file
208
DysonNetwork.Pass/Lotteries/LotteryService.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Lotteries;
|
||||||
|
|
||||||
|
public class LotteryService(AppDatabase db, PaymentService paymentService, WalletService walletService)
|
||||||
|
{
|
||||||
|
private static bool ValidateNumbers(List<int> region1, int region2)
|
||||||
|
{
|
||||||
|
if (region1.Count != 5 || region1.Distinct().Count() != 5)
|
||||||
|
return false;
|
||||||
|
if (region1.Any(n => n < 0 || n > 99))
|
||||||
|
return false;
|
||||||
|
if (region2 < 0 || region2 > 99)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnLottery> CreateTicketAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
|
||||||
|
{
|
||||||
|
if (!ValidateNumbers(region1, region2))
|
||||||
|
throw new ArgumentException("Invalid lottery numbers");
|
||||||
|
|
||||||
|
var lottery = new SnLottery
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
RegionOneNumbers = region1,
|
||||||
|
RegionTwoNumber = region2,
|
||||||
|
Multiplier = multiplier
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Lotteries.Add(lottery);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return lottery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnLottery>> GetUserTicketsAsync(Guid accountId, int offset = 0, int limit = 20)
|
||||||
|
{
|
||||||
|
return await db.Lotteries
|
||||||
|
.Where(l => l.AccountId == accountId)
|
||||||
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnLottery?> GetTicketAsync(Guid id)
|
||||||
|
{
|
||||||
|
return await db.Lotteries.FirstOrDefaultAsync(l => l.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetUserTicketCountAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
return await db.Lotteries.CountAsync(l => l.AccountId == accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateLotteryPrice(int multiplier)
|
||||||
|
{
|
||||||
|
return 10 + (multiplier - 1) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnWalletOrder> CreateLotteryOrderAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
|
||||||
|
{
|
||||||
|
if (!ValidateNumbers(region1, region2))
|
||||||
|
throw new ArgumentException("Invalid lottery numbers");
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant();
|
||||||
|
var hasPurchasedToday = await db.Lotteries.AnyAsync(l => l.AccountId == accountId && l.CreatedAt >= todayStart);
|
||||||
|
if (hasPurchasedToday)
|
||||||
|
throw new InvalidOperationException("You can only purchase one lottery per day.");
|
||||||
|
|
||||||
|
var price = CalculateLotteryPrice(multiplier);
|
||||||
|
|
||||||
|
return await paymentService.CreateOrderAsync(
|
||||||
|
null,
|
||||||
|
"isp",
|
||||||
|
price,
|
||||||
|
appIdentifier: "lottery",
|
||||||
|
productIdentifier: "lottery",
|
||||||
|
meta: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["account_id"] = accountId.ToString(),
|
||||||
|
["region_one_numbers"] = region1,
|
||||||
|
["region_two_number"] = region2,
|
||||||
|
["multiplier"] = multiplier
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleLotteryOrder(SnWalletOrder order)
|
||||||
|
{
|
||||||
|
if (order.Status != OrderStatus.Paid ||
|
||||||
|
!order.Meta.TryGetValue("account_id", out var accountIdValue) ||
|
||||||
|
!order.Meta.TryGetValue("region_one_numbers", out var region1Value) ||
|
||||||
|
!order.Meta.TryGetValue("region_two_number", out var region2Value) ||
|
||||||
|
!order.Meta.TryGetValue("multiplier", out var multiplierValue))
|
||||||
|
throw new InvalidOperationException("Invalid order.");
|
||||||
|
|
||||||
|
var accountId = Guid.Parse((string)accountIdValue!);
|
||||||
|
var region1Json = (System.Text.Json.JsonElement)region1Value;
|
||||||
|
var region1 = region1Json.EnumerateArray().Select(e => e.GetInt32()).ToList();
|
||||||
|
var region2 = Convert.ToInt32((string)region2Value!);
|
||||||
|
var multiplier = Convert.ToInt32((string)multiplierValue!);
|
||||||
|
|
||||||
|
await CreateTicketAsync(accountId, region1, region2, multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CalculateReward(int region1Matches, bool region2Match)
|
||||||
|
{
|
||||||
|
var reward = region1Matches switch
|
||||||
|
{
|
||||||
|
0 => 0,
|
||||||
|
1 => 10,
|
||||||
|
2 => 20,
|
||||||
|
3 => 50,
|
||||||
|
4 => 100,
|
||||||
|
5 => 1000,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
if (region2Match) reward *= 10;
|
||||||
|
return reward;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<int> GenerateUniqueRandomNumbers(int count, int min, int max)
|
||||||
|
{
|
||||||
|
var numbers = new List<int>();
|
||||||
|
var random = new Random();
|
||||||
|
while (numbers.Count < count)
|
||||||
|
{
|
||||||
|
var num = random.Next(min, max + 1);
|
||||||
|
if (!numbers.Contains(num)) numbers.Add(num);
|
||||||
|
}
|
||||||
|
return numbers.OrderBy(n => n).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountMatches(List<int> playerNumbers, List<int> winningNumbers)
|
||||||
|
{
|
||||||
|
return playerNumbers.Intersect(winningNumbers).Count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PerformDailyDrawAsync()
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var yesterdayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant().Minus(Duration.FromDays(1));
|
||||||
|
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant();
|
||||||
|
|
||||||
|
// Tickets purchased yesterday that are still pending draw
|
||||||
|
var tickets = await db.Lotteries
|
||||||
|
.Where(l => l.CreatedAt >= yesterdayStart && l.CreatedAt < todayStart && l.DrawStatus == LotteryDrawStatus.Pending)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!tickets.Any()) return;
|
||||||
|
|
||||||
|
// Generate winning numbers
|
||||||
|
var winningRegion1 = GenerateUniqueRandomNumbers(5, 0, 99);
|
||||||
|
var winningRegion2 = GenerateUniqueRandomNumbers(1, 0, 99)[0];
|
||||||
|
|
||||||
|
var drawDate = Instant.FromDateTimeUtc(DateTime.Today.AddDays(-1)); // Yesterday's date
|
||||||
|
|
||||||
|
var totalPrizesAwarded = 0;
|
||||||
|
long totalPrizeAmount = 0;
|
||||||
|
|
||||||
|
// Process each ticket
|
||||||
|
foreach (var ticket in tickets)
|
||||||
|
{
|
||||||
|
var region1Matches = CountMatches(ticket.RegionOneNumbers, winningRegion1);
|
||||||
|
var region2Match = ticket.RegionTwoNumber == winningRegion2;
|
||||||
|
var reward = CalculateReward(region1Matches, region2Match);
|
||||||
|
|
||||||
|
if (reward > 0)
|
||||||
|
{
|
||||||
|
var wallet = await walletService.GetWalletAsync(ticket.AccountId);
|
||||||
|
if (wallet != null)
|
||||||
|
{
|
||||||
|
await paymentService.CreateTransactionAsync(
|
||||||
|
payerWalletId: null,
|
||||||
|
payeeWalletId: wallet.Id,
|
||||||
|
currency: "isp",
|
||||||
|
amount: reward,
|
||||||
|
remarks: $"Lottery prize: {region1Matches} matches{(region2Match ? " + special" : "")}"
|
||||||
|
);
|
||||||
|
totalPrizesAwarded++;
|
||||||
|
totalPrizeAmount += reward;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket.DrawStatus = LotteryDrawStatus.Drawn;
|
||||||
|
ticket.DrawDate = drawDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the draw record
|
||||||
|
var lotteryRecord = new SnLotteryRecord
|
||||||
|
{
|
||||||
|
DrawDate = drawDate,
|
||||||
|
WinningRegionOneNumbers = winningRegion1,
|
||||||
|
WinningRegionTwoNumber = winningRegion2,
|
||||||
|
TotalTickets = tickets.Count,
|
||||||
|
TotalPrizesAwarded = totalPrizesAwarded,
|
||||||
|
TotalPrizeAmount = totalPrizeAmount
|
||||||
|
};
|
||||||
|
|
||||||
|
db.LotteryRecords.Add(lotteryRecord);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal file
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2359
DysonNetwork.Pass/Migrations/20251008050851_AddUsernameColor.Designer.cs
generated
Normal file
2359
DysonNetwork.Pass/Migrations/20251008050851_AddUsernameColor.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2677
DysonNetwork.Pass/Migrations/20251021153439_AddRealmFromSphere.Designer.cs
generated
Normal file
2677
DysonNetwork.Pass/Migrations/20251021153439_AddRealmFromSphere.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
|||||||
|
using System;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRealmFromSphere : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "realms",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
is_community = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_realms", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "realm_members",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
role = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
leave_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_realm_members", x => new { x.realm_id, x.account_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_realm_members_realms_realm_id",
|
||||||
|
column: x => x.realm_id,
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sn_chat_room",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
is_community = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
sn_realm_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_sn_chat_room", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_sn_chat_room_realms_sn_realm_id",
|
||||||
|
column: x => x.sn_realm_id,
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sn_chat_member",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
role = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
notify = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
is_bot = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", 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_sn_chat_member", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
|
||||||
|
column: x => x.chat_room_id,
|
||||||
|
principalTable: "sn_chat_room",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_realms_slug",
|
||||||
|
table: "realms",
|
||||||
|
column: "slug",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_sn_chat_member_chat_room_id",
|
||||||
|
table: "sn_chat_member",
|
||||||
|
column: "chat_room_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_sn_chat_room_sn_realm_id",
|
||||||
|
table: "sn_chat_room",
|
||||||
|
column: "sn_realm_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "realm_members");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sn_chat_member");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sn_chat_room");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "realms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2497
DysonNetwork.Pass/Migrations/20251022164134_RemoveChatRoom.Designer.cs
generated
Normal file
2497
DysonNetwork.Pass/Migrations/20251022164134_RemoveChatRoom.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveChatRoom : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sn_chat_member");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sn_chat_room");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sn_chat_room",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
is_community = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_sn_chat_room", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_sn_chat_room_realms_sn_realm_id",
|
||||||
|
column: x => x.sn_realm_id,
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sn_chat_member",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
is_bot = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
notify = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
role = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
|
||||||
|
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_sn_chat_member", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
|
||||||
|
column: x => x.chat_room_id,
|
||||||
|
principalTable: "sn_chat_room",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_sn_chat_member_chat_room_id",
|
||||||
|
table: "sn_chat_member",
|
||||||
|
column: "chat_room_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_sn_chat_room_sn_realm_id",
|
||||||
|
table: "sn_chat_room",
|
||||||
|
column: "sn_realm_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2612
DysonNetwork.Pass/Migrations/20251023173204_AddLotteries.Designer.cs
generated
Normal file
2612
DysonNetwork.Pass/Migrations/20251023173204_AddLotteries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
DysonNetwork.Pass/Migrations/20251023173204_AddLotteries.cs
Normal file
78
DysonNetwork.Pass/Migrations/20251023173204_AddLotteries.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLotteries : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "lotteries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
|
||||||
|
region_two_number = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
multiplier = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
draw_status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
draw_date = 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_lotteries", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_lotteries_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "lottery_records",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
winning_region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
|
||||||
|
winning_region_two_number = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
total_tickets = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
total_prizes_awarded = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
total_prize_amount = table.Column<long>(type: "bigint", 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_lottery_records", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_lotteries_account_id",
|
||||||
|
table: "lotteries",
|
||||||
|
column: "account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "lotteries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "lottery_records");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.7")
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
@@ -478,6 +478,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<UsernameColor>("UsernameColor")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("username_color");
|
||||||
|
|
||||||
b.Property<SnVerificationMark>("Verification")
|
b.Property<SnVerificationMark>("Verification")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("verification");
|
.HasColumnName("verification");
|
||||||
@@ -1055,6 +1059,109 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("experience_records", (string)null);
|
b.ToTable("experience_records", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DrawDate")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("draw_date");
|
||||||
|
|
||||||
|
b.Property<int>("DrawStatus")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("draw_status");
|
||||||
|
|
||||||
|
b.Property<int>("Multiplier")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("multiplier");
|
||||||
|
|
||||||
|
b.Property<List<int>>("RegionOneNumbers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("region_one_numbers");
|
||||||
|
|
||||||
|
b.Property<int>("RegionTwoNumber")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("region_two_number");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_lotteries");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_lotteries_account_id");
|
||||||
|
|
||||||
|
b.ToTable("lotteries", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLotteryRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<Instant>("DrawDate")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("draw_date");
|
||||||
|
|
||||||
|
b.Property<long>("TotalPrizeAmount")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("total_prize_amount");
|
||||||
|
|
||||||
|
b.Property<int>("TotalPrizesAwarded")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("total_prizes_awarded");
|
||||||
|
|
||||||
|
b.Property<int>("TotalTickets")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("total_tickets");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<List<int>>("WinningRegionOneNumbers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("winning_region_one_numbers");
|
||||||
|
|
||||||
|
b.Property<int>("WinningRegionTwoNumber")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("winning_region_two_number");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_lottery_records");
|
||||||
|
|
||||||
|
b.ToTable("lottery_records", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1248,6 +1355,127 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("permission_nodes", (string)null);
|
b.ToTable("permission_nodes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<SnCloudFileReferenceObject>("Background")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("background");
|
||||||
|
|
||||||
|
b.Property<string>("BackgroundId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("background_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCommunity")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_community");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_public");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("picture");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("picture_id");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("slug");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<SnVerificationMark>("Verification")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("verification");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_realms");
|
||||||
|
|
||||||
|
b.HasIndex("Slug")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_realms_slug");
|
||||||
|
|
||||||
|
b.ToTable("realms", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("RealmId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("realm_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("joined_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("LeaveAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("leave_at");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("role");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("RealmId", "AccountId")
|
||||||
|
.HasName("pk_realm_members");
|
||||||
|
|
||||||
|
b.ToTable("realm_members", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1387,6 +1615,116 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("wallet_coupons", (string)null);
|
b.ToTable("wallet_coupons", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatorAccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("creator_account_id");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<Instant>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("message");
|
||||||
|
|
||||||
|
b.Property<int>("SplitType")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("split_type");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalAmount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("total_amount");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_wallet_funds");
|
||||||
|
|
||||||
|
b.HasIndex("CreatorAccountId")
|
||||||
|
.HasDatabaseName("ix_wallet_funds_creator_account_id");
|
||||||
|
|
||||||
|
b.ToTable("wallet_funds", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("amount");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("FundId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("fund_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReceived")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_received");
|
||||||
|
|
||||||
|
b.Property<Instant?>("ReceivedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("received_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecipientAccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("recipient_account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_wallet_fund_recipients");
|
||||||
|
|
||||||
|
b.HasIndex("FundId")
|
||||||
|
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
|
||||||
|
|
||||||
|
b.HasIndex("RecipientAccountId")
|
||||||
|
.HasDatabaseName("ix_wallet_fund_recipients_recipient_account_id");
|
||||||
|
|
||||||
|
b.ToTable("wallet_fund_recipients", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1464,6 +1802,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("status");
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SubscriptionId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("subscription_id");
|
||||||
|
|
||||||
b.Property<string>("SubscriptionIdentifier")
|
b.Property<string>("SubscriptionIdentifier")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(4096)
|
||||||
@@ -1492,6 +1834,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.HasIndex("RedeemerId")
|
b.HasIndex("RedeemerId")
|
||||||
.HasDatabaseName("ix_wallet_gifts_redeemer_id");
|
.HasDatabaseName("ix_wallet_gifts_redeemer_id");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriptionId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_wallet_gifts_subscription_id");
|
||||||
|
|
||||||
b.ToTable("wallet_gifts", (string)null);
|
b.ToTable("wallet_gifts", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1648,10 +1994,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("ended_at");
|
.HasColumnName("ended_at");
|
||||||
|
|
||||||
b.Property<Guid?>("GiftId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("gift_id");
|
|
||||||
|
|
||||||
b.Property<string>("Identifier")
|
b.Property<string>("Identifier")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(4096)
|
||||||
@@ -1698,10 +2040,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.HasIndex("CouponId")
|
b.HasIndex("CouponId")
|
||||||
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
|
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
|
||||||
|
|
||||||
b.HasIndex("GiftId")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_wallet_subscriptions_gift_id");
|
|
||||||
|
|
||||||
b.HasIndex("Identifier")
|
b.HasIndex("Identifier")
|
||||||
.HasDatabaseName("ix_wallet_subscriptions_identifier");
|
.HasDatabaseName("ix_wallet_subscriptions_identifier");
|
||||||
|
|
||||||
@@ -1999,6 +2337,18 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_lotteries_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||||
@@ -2031,6 +2381,18 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Group");
|
b.Navigation("Group");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")
|
||||||
|
.WithMany("Members")
|
||||||
|
.HasForeignKey("RealmId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_realm_members_realms_realm_id");
|
||||||
|
|
||||||
|
b.Navigation("Realm");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||||
@@ -2055,6 +2417,39 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "CreatorAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_wallet_funds_accounts_creator_account_id");
|
||||||
|
|
||||||
|
b.Navigation("CreatorAccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
|
||||||
|
.WithMany("Recipients")
|
||||||
|
.HasForeignKey("FundId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "RecipientAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RecipientAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_wallet_fund_recipients_accounts_recipient_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Fund");
|
||||||
|
|
||||||
|
b.Navigation("RecipientAccount");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
|
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
|
||||||
@@ -2079,6 +2474,11 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasForeignKey("RedeemerId")
|
.HasForeignKey("RedeemerId")
|
||||||
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
|
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
|
||||||
|
.WithOne("Gift")
|
||||||
|
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
|
||||||
|
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
|
||||||
|
|
||||||
b.Navigation("Coupon");
|
b.Navigation("Coupon");
|
||||||
|
|
||||||
b.Navigation("Gifter");
|
b.Navigation("Gifter");
|
||||||
@@ -2086,6 +2486,8 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Recipient");
|
b.Navigation("Recipient");
|
||||||
|
|
||||||
b.Navigation("Redeemer");
|
b.Navigation("Redeemer");
|
||||||
|
|
||||||
|
b.Navigation("Subscription");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
|
||||||
@@ -2131,16 +2533,9 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasForeignKey("CouponId")
|
.HasForeignKey("CouponId")
|
||||||
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
|
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
|
||||||
|
|
||||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletGift", "Gift")
|
|
||||||
.WithOne("Subscription")
|
|
||||||
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletSubscription", "GiftId")
|
|
||||||
.HasConstraintName("fk_wallet_subscriptions_wallet_gifts_gift_id");
|
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
|
|
||||||
b.Navigation("Coupon");
|
b.Navigation("Coupon");
|
||||||
|
|
||||||
b.Navigation("Gift");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
|
||||||
@@ -2189,14 +2584,24 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Nodes");
|
b.Navigation("Nodes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Pockets");
|
b.Navigation("Pockets");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Subscription");
|
b.Navigation("Recipients");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Gift");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
|||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddRingService();
|
builder.Services.AddRingService();
|
||||||
builder.Services.AddDriveService();
|
builder.Services.AddDriveService();
|
||||||
@@ -36,7 +35,14 @@ app.MapDefaultEndpoints();
|
|||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
catch (Exception err)
|
||||||
|
{
|
||||||
|
Console.WriteLine(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure application middleware pipeline
|
// Configure application middleware pipeline
|
||||||
@@ -45,6 +51,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
|
|||||||
// Configure gRPC
|
// Configure gRPC
|
||||||
app.ConfigureGrpcServices();
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
app.UseSwaggerManifest();
|
app.UseSwaggerManifest("DysonNetwork.Pass");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using AccountService = DysonNetwork.Pass.Account.AccountService;
|
||||||
using DysonNetwork.Shared.Models;
|
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Realm;
|
namespace DysonNetwork.Pass.Realm;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/realms")]
|
[Route("/api/realms")]
|
||||||
@@ -17,9 +20,9 @@ public class RealmController(
|
|||||||
RealmService rs,
|
RealmService rs,
|
||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
ActionLogService.ActionLogServiceClient als,
|
ActionLogService als,
|
||||||
AccountService.AccountServiceClient accounts,
|
RelationshipService rels,
|
||||||
AccountClientHelper accountsHelper
|
AccountEventService accountEvents
|
||||||
) : Controller
|
) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("{slug}")]
|
[HttpGet("{slug}")]
|
||||||
@@ -37,13 +40,12 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms()
|
public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var members = await db.RealmMembers
|
var members = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
.Where(m => m.JoinedAt != null)
|
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||||
.Where(m => m.LeaveAt == null)
|
|
||||||
.Include(e => e.Realm)
|
.Include(e => e.Realm)
|
||||||
.Select(m => m.Realm)
|
.Select(m => m.Realm)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -55,8 +57,8 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnRealmMember>>> ListInvites()
|
public async Task<ActionResult<List<SnRealmMember>>> ListInvites()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var members = await db.RealmMembers
|
var members = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
@@ -78,20 +80,18 @@ public class RealmController(
|
|||||||
public async Task<ActionResult<SnRealmMember>> InviteMember(string slug,
|
public async Task<ActionResult<SnRealmMember>> InviteMember(string slug,
|
||||||
[FromBody] RealmMemberRequest request)
|
[FromBody] RealmMemberRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var relatedUser =
|
var relatedUser = await db.Accounts.Where(a => a.Id == request.RelatedUserId).FirstOrDefaultAsync();
|
||||||
await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
|
|
||||||
if (relatedUser == null) return BadRequest("Related user was not found");
|
if (relatedUser == null) return BadRequest("Related user was not found");
|
||||||
|
|
||||||
var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest()
|
var hasBlocked = await rels.HasRelationshipWithStatus(
|
||||||
{
|
currentUser.Id,
|
||||||
AccountId = currentUser.Id,
|
request.RelatedUserId,
|
||||||
RelatedId = request.RelatedUserId.ToString(),
|
RelationshipStatus.Blocked
|
||||||
Status = -100
|
);
|
||||||
});
|
if (hasBlocked)
|
||||||
if (hasBlocked?.Value ?? false)
|
|
||||||
return StatusCode(403, "You cannot invite a user that blocked you.");
|
return StatusCode(403, "You cannot invite a user that blocked you.");
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await db.Realms
|
||||||
@@ -102,17 +102,38 @@ public class RealmController(
|
|||||||
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
|
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
|
||||||
return StatusCode(403, "You cannot invite member has higher permission than yours.");
|
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.AccountId == relatedUser.Id)
|
||||||
.Where(m => m.RealmId == realm.Id)
|
.Where(m => m.RealmId == realm.Id)
|
||||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
.FirstOrDefaultAsync();
|
||||||
.AnyAsync();
|
if (existingMember != null)
|
||||||
if (hasExistingMember)
|
{
|
||||||
|
if (existingMember.LeaveAt == null)
|
||||||
return BadRequest("This user already in the realm cannot be invited again.");
|
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.CreateActionLogFromRequest(
|
||||||
|
"realms.members.invite",
|
||||||
|
new Dictionary<string, object>()
|
||||||
|
{
|
||||||
|
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
||||||
|
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) },
|
||||||
|
{ "role", Value.ForNumber(request.Role) }
|
||||||
|
},
|
||||||
|
Request
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(existingMember);
|
||||||
|
}
|
||||||
|
|
||||||
var member = new SnRealmMember
|
var member = new SnRealmMember
|
||||||
{
|
{
|
||||||
AccountId = Guid.Parse(relatedUser.Id),
|
AccountId = relatedUser.Id,
|
||||||
RealmId = realm.Id,
|
RealmId = realm.Id,
|
||||||
Role = request.Role,
|
Role = request.Role,
|
||||||
};
|
};
|
||||||
@@ -120,21 +141,18 @@ public class RealmController(
|
|||||||
db.RealmMembers.Add(member);
|
db.RealmMembers.Add(member);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
{
|
"realms.members.invite",
|
||||||
Action = "realms.members.invite",
|
new Dictionary<string, object>()
|
||||||
Meta =
|
|
||||||
{
|
{
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
||||||
{ "account_id", Value.ForString(member.AccountId.ToString()) },
|
{ "account_id", Value.ForString(member.AccountId.ToString()) },
|
||||||
{ "role", Value.ForNumber(request.Role) }
|
{ "role", Value.ForNumber(request.Role) }
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
member.AccountId = Guid.Parse(relatedUser.Id);
|
member.AccountId = relatedUser.Id;
|
||||||
member.Realm = realm;
|
member.Realm = realm;
|
||||||
await rs.SendInviteNotify(member);
|
await rs.SendInviteNotify(member);
|
||||||
|
|
||||||
@@ -145,8 +163,8 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug)
|
public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var member = await db.RealmMembers
|
var member = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
@@ -159,18 +177,15 @@ public class RealmController(
|
|||||||
db.Update(member);
|
db.Update(member);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.members.join",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.members.join",
|
{ "realm_id", member.RealmId.ToString() },
|
||||||
Meta =
|
{ "account_id", member.AccountId.ToString() }
|
||||||
{
|
|
||||||
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
|
|
||||||
{ "account_id", Value.ForString(member.AccountId.ToString()) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(member);
|
return Ok(member);
|
||||||
}
|
}
|
||||||
@@ -179,8 +194,8 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> DeclineMemberInvite(string slug)
|
public async Task<ActionResult> DeclineMemberInvite(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var member = await db.RealmMembers
|
var member = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
@@ -192,19 +207,16 @@ public class RealmController(
|
|||||||
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
{
|
"realms.members.decline_invite",
|
||||||
Action = "realms.members.decline_invite",
|
new Dictionary<string, object>()
|
||||||
Meta =
|
|
||||||
{
|
{
|
||||||
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
|
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
|
||||||
{ "account_id", Value.ForString(member.AccountId.ToString()) },
|
{ "account_id", Value.ForString(member.AccountId.ToString()) },
|
||||||
{ "decliner_id", Value.ForString(currentUser.Id) }
|
{ "decliner_id", Value.ForString(currentUser.Id.ToString()) }
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -225,8 +237,8 @@ public class RealmController(
|
|||||||
|
|
||||||
if (!realm.IsPublic)
|
if (!realm.IsPublic)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal))
|
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
|
||||||
return StatusCode(403, "You must be a member to view this realm's members.");
|
return StatusCode(403, "You must be a member to view this realm's members.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,7 @@ public class RealmController(
|
|||||||
.OrderBy(m => m.JoinedAt)
|
.OrderBy(m => m.JoinedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var memberStatuses = await accountsHelper.GetAccountStatusBatch(
|
var memberStatuses = await accountEvents.GetStatuses(
|
||||||
members.Select(m => m.AccountId).ToList()
|
members.Select(m => m.AccountId).ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -283,8 +295,8 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug)
|
public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var member = await db.RealmMembers
|
var member = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
@@ -300,8 +312,8 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> LeaveRealm(string slug)
|
public async Task<ActionResult> LeaveRealm(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
|
|
||||||
var member = await db.RealmMembers
|
var member = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId)
|
.Where(m => m.AccountId == accountId)
|
||||||
@@ -316,19 +328,16 @@ public class RealmController(
|
|||||||
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.members.leave",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.members.leave",
|
{ "realm_id", member.RealmId.ToString() },
|
||||||
Meta =
|
{ "account_id", member.AccountId.ToString() },
|
||||||
{
|
{ "leaver_id", currentUser.Id }
|
||||||
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
|
|
||||||
{ "account_id", Value.ForString(member.AccountId.ToString()) },
|
|
||||||
{ "leaver_id", Value.ForString(currentUser.Id) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -348,7 +357,7 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request)
|
public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
|
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
|
||||||
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
|
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
|
||||||
|
|
||||||
@@ -360,7 +369,7 @@ public class RealmController(
|
|||||||
Name = request.Name!,
|
Name = request.Name!,
|
||||||
Slug = request.Slug!,
|
Slug = request.Slug!,
|
||||||
Description = request.Description!,
|
Description = request.Description!,
|
||||||
AccountId = Guid.Parse(currentUser.Id),
|
AccountId = currentUser.Id,
|
||||||
IsCommunity = request.IsCommunity ?? false,
|
IsCommunity = request.IsCommunity ?? false,
|
||||||
IsPublic = request.IsPublic ?? false,
|
IsPublic = request.IsPublic ?? false,
|
||||||
Members = new List<SnRealmMember>
|
Members = new List<SnRealmMember>
|
||||||
@@ -368,7 +377,7 @@ public class RealmController(
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Role = RealmMemberRole.Owner,
|
Role = RealmMemberRole.Owner,
|
||||||
AccountId = Guid.Parse(currentUser.Id),
|
AccountId = currentUser.Id,
|
||||||
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
|
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,21 +400,18 @@ public class RealmController(
|
|||||||
db.Realms.Add(realm);
|
db.Realms.Add(realm);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.create",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.create",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "name", realm.Name },
|
||||||
{
|
{ "slug", realm.Slug },
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
{ "is_community", realm.IsCommunity },
|
||||||
{ "name", Value.ForString(realm.Name) },
|
{ "is_public", realm.IsPublic }
|
||||||
{ "slug", Value.ForString(realm.Slug) },
|
|
||||||
{ "is_community", Value.ForBool(realm.IsCommunity) },
|
|
||||||
{ "is_public", Value.ForBool(realm.IsPublic) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
var realmResourceId = $"realm:{realm.Id}";
|
var realmResourceId = $"realm:{realm.Id}";
|
||||||
|
|
||||||
@@ -436,14 +442,14 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request)
|
public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await db.Realms
|
||||||
.Where(r => r.Slug == slug)
|
.Where(r => r.Slug == slug)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (realm is null) return NotFound();
|
if (realm is null) return NotFound();
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = currentUser.Id;
|
||||||
var member = await db.RealmMembers
|
var member = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
@@ -519,24 +525,21 @@ public class RealmController(
|
|||||||
db.Realms.Update(realm);
|
db.Realms.Update(realm);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.update",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.update",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "name_updated", request.Name != null },
|
||||||
{
|
{ "slug_updated", request.Slug != null },
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
{ "description_updated", request.Description != null },
|
||||||
{ "name_updated", Value.ForBool(request.Name != null) },
|
{ "picture_updated", request.PictureId != null },
|
||||||
{ "slug_updated", Value.ForBool(request.Slug != null) },
|
{ "background_updated", request.BackgroundId != null },
|
||||||
{ "description_updated", Value.ForBool(request.Description != null) },
|
{ "is_community_updated", request.IsCommunity != null },
|
||||||
{ "picture_updated", Value.ForBool(request.PictureId != null) },
|
{ "is_public_updated", request.IsPublic != null }
|
||||||
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
|
|
||||||
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
|
|
||||||
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(realm);
|
return Ok(realm);
|
||||||
}
|
}
|
||||||
@@ -545,7 +548,7 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug)
|
public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await db.Realms
|
||||||
.Where(r => r.Slug == slug)
|
.Where(r => r.Slug == slug)
|
||||||
@@ -556,14 +559,36 @@ public class RealmController(
|
|||||||
return StatusCode(403, "Only community realms can be joined without invitation.");
|
return StatusCode(403, "Only community realms can be joined without invitation.");
|
||||||
|
|
||||||
var existingMember = await db.RealmMembers
|
var existingMember = await db.RealmMembers
|
||||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
.Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (existingMember is not null)
|
if (existingMember is not null)
|
||||||
|
{
|
||||||
|
if (existingMember.LeaveAt == null)
|
||||||
return BadRequest("You are already a member of this realm.");
|
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.CreateActionLogFromRequest(
|
||||||
|
"realms.members.join",
|
||||||
|
new Dictionary<string, object>()
|
||||||
|
{
|
||||||
|
{ "realm_id", existingMember.RealmId.ToString() },
|
||||||
|
{ "account_id", currentUser.Id },
|
||||||
|
{ "is_community", realm.IsCommunity }
|
||||||
|
},
|
||||||
|
Request
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(existingMember);
|
||||||
|
}
|
||||||
|
|
||||||
var member = new SnRealmMember
|
var member = new SnRealmMember
|
||||||
{
|
{
|
||||||
AccountId = Guid.Parse(currentUser.Id),
|
AccountId = currentUser.Id,
|
||||||
RealmId = realm.Id,
|
RealmId = realm.Id,
|
||||||
Role = RealmMemberRole.Normal,
|
Role = RealmMemberRole.Normal,
|
||||||
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
|
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
|
||||||
@@ -572,19 +597,16 @@ public class RealmController(
|
|||||||
db.RealmMembers.Add(member);
|
db.RealmMembers.Add(member);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.members.join",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.members.join",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "account_id", currentUser.Id },
|
||||||
{
|
{ "is_community", realm.IsCommunity }
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
|
||||||
{ "account_id", Value.ForString(currentUser.Id) },
|
|
||||||
{ "is_community", Value.ForBool(realm.IsCommunity) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(member);
|
return Ok(member);
|
||||||
}
|
}
|
||||||
@@ -593,7 +615,7 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
|
public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await db.Realms
|
||||||
.Where(r => r.Slug == slug)
|
.Where(r => r.Slug == slug)
|
||||||
@@ -605,25 +627,22 @@ public class RealmController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (member is null) return NotFound();
|
if (member is null) return NotFound();
|
||||||
|
|
||||||
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role))
|
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role))
|
||||||
return StatusCode(403, "You do not have permission to remove members from this realm.");
|
return StatusCode(403, "You do not have permission to remove members from this realm.");
|
||||||
|
|
||||||
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.members.kick",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.members.kick",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "account_id", memberId.ToString() },
|
||||||
{
|
{ "kicker_id", currentUser.Id }
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
|
||||||
{ "account_id", Value.ForString(memberId.ToString()) },
|
|
||||||
{ "kicker_id", Value.ForString(currentUser.Id) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -633,7 +652,7 @@ public class RealmController(
|
|||||||
public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
|
public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
|
||||||
{
|
{
|
||||||
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
|
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await db.Realms
|
||||||
.Where(r => r.Slug == slug)
|
.Where(r => r.Slug == slug)
|
||||||
@@ -645,7 +664,7 @@ public class RealmController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (member is null) return NotFound();
|
if (member is null) return NotFound();
|
||||||
|
|
||||||
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role,
|
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role,
|
||||||
newRole))
|
newRole))
|
||||||
return StatusCode(403, "You do not have permission to update member roles in this realm.");
|
return StatusCode(403, "You do not have permission to update member roles in this realm.");
|
||||||
|
|
||||||
@@ -653,20 +672,17 @@ public class RealmController(
|
|||||||
db.RealmMembers.Update(member);
|
db.RealmMembers.Update(member);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.members.role_update",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.members.role_update",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "account_id", memberId.ToString() },
|
||||||
{
|
{ "new_role", newRole },
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
{ "updater_id", currentUser.Id }
|
||||||
{ "account_id", Value.ForString(memberId.ToString()) },
|
|
||||||
{ "new_role", Value.ForNumber(newRole) },
|
|
||||||
{ "updater_id", Value.ForString(currentUser.Id) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(member);
|
return Ok(member);
|
||||||
}
|
}
|
||||||
@@ -675,7 +691,7 @@ public class RealmController(
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> Delete(string slug)
|
public async Task<ActionResult> Delete(string slug)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var transaction = await db.Database.BeginTransactionAsync();
|
var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -684,16 +700,11 @@ public class RealmController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (realm is null) return NotFound();
|
if (realm is null) return NotFound();
|
||||||
|
|
||||||
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner))
|
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Owner))
|
||||||
return StatusCode(403, "Only the owner can delete this realm.");
|
return StatusCode(403, "Only the owner can delete this realm.");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var chats = await db.ChatRooms
|
|
||||||
.Where(c => c.RealmId == realm.Id)
|
|
||||||
.Select(c => c.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
db.Realms.Remove(realm);
|
db.Realms.Remove(realm);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -701,15 +712,6 @@ public class RealmController(
|
|||||||
await db.RealmMembers
|
await db.RealmMembers
|
||||||
.Where(m => m.RealmId == realm.Id)
|
.Where(m => m.RealmId == realm.Id)
|
||||||
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
|
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
|
||||||
await db.ChatRooms
|
|
||||||
.Where(c => c.RealmId == realm.Id)
|
|
||||||
.ExecuteUpdateAsync(c => c.SetProperty(c => c.DeletedAt, now));
|
|
||||||
await db.ChatMessages
|
|
||||||
.Where(m => chats.Contains(m.ChatRoomId))
|
|
||||||
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
|
|
||||||
await db.ChatMembers
|
|
||||||
.Where(m => chats.Contains(m.ChatRoomId))
|
|
||||||
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
}
|
}
|
||||||
@@ -719,19 +721,16 @@ public class RealmController(
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
als.CreateActionLogFromRequest(
|
||||||
|
"realms.delete",
|
||||||
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
Action = "realms.delete",
|
{ "realm_id", realm.Id.ToString() },
|
||||||
Meta =
|
{ "realm_name", realm.Name },
|
||||||
{
|
{ "realm_slug", realm.Slug }
|
||||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
|
||||||
{ "realm_name", Value.ForString(realm.Name) },
|
|
||||||
{ "realm_slug", Value.ForString(realm.Slug) }
|
|
||||||
},
|
},
|
||||||
AccountId = currentUser.Id,
|
Request
|
||||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
);
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete all file references for this realm
|
// Delete all file references for this realm
|
||||||
var realmResourceId = $"realm:{realm.Id}";
|
var realmResourceId = $"realm:{realm.Id}";
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
|
using DysonNetwork.Pass.Localization;
|
||||||
using DysonNetwork.Shared;
|
using DysonNetwork.Shared;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using DysonNetwork.Sphere.Localization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Realm;
|
namespace DysonNetwork.Pass.Realm;
|
||||||
|
|
||||||
public class RealmService(
|
public class RealmService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
RingService.RingServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
AccountService.AccountServiceClient accounts,
|
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
AccountClientHelper accountsHelper,
|
|
||||||
ICacheService cache
|
ICacheService cache
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -42,13 +40,18 @@ public class RealmService(
|
|||||||
|
|
||||||
public async Task SendInviteNotify(SnRealmMember member)
|
public async Task SendInviteNotify(SnRealmMember member)
|
||||||
{
|
{
|
||||||
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
|
var account = await db.Accounts
|
||||||
CultureService.SetCultureInfo(account);
|
.Include(a => a.Profile)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
|
||||||
|
|
||||||
|
if (account == null) throw new InvalidOperationException("Account not found");
|
||||||
|
|
||||||
|
CultureService.SetCultureInfo(account.Language);
|
||||||
|
|
||||||
await pusher.SendPushNotificationToUserAsync(
|
await pusher.SendPushNotificationToUserAsync(
|
||||||
new SendPushNotificationToUserRequest
|
new SendPushNotificationToUserRequest
|
||||||
{
|
{
|
||||||
UserId = account.Id,
|
UserId = account.Id.ToString(),
|
||||||
Notification = new PushNotification
|
Notification = new PushNotification
|
||||||
{
|
{
|
||||||
Topic = "invites.realms",
|
Topic = "invites.realms",
|
||||||
@@ -75,20 +78,26 @@ public class RealmService(
|
|||||||
|
|
||||||
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
|
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
|
||||||
{
|
{
|
||||||
var account = await accountsHelper.GetAccount(member.AccountId);
|
var account = await db.Accounts
|
||||||
member.Account = SnAccount.FromProtoValue(account);
|
.Include(a => a.Profile)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
|
||||||
|
if (account != null)
|
||||||
|
member.Account = account;
|
||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members)
|
public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members)
|
||||||
{
|
{
|
||||||
var accountIds = members.Select(m => m.AccountId).ToList();
|
var accountIds = members.Select(m => m.AccountId).ToList();
|
||||||
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
|
var accountsDict = await db.Accounts
|
||||||
|
.Include(a => a.Profile)
|
||||||
|
.Where(a => accountIds.Contains(a.Id))
|
||||||
|
.ToDictionaryAsync(a => a.Id, a => a);
|
||||||
|
|
||||||
return members.Select(m =>
|
return members.Select(m =>
|
||||||
{
|
{
|
||||||
if (accounts.TryGetValue(m.AccountId, out var account))
|
if (accountsDict.TryGetValue(m.AccountId, out var account))
|
||||||
m.Account = SnAccount.FromProtoValue(account);
|
m.Account = account;
|
||||||
return m;
|
return m;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
170
DysonNetwork.Pass/Realm/RealmServiceGrpc.cs
Normal file
170
DysonNetwork.Pass/Realm/RealmServiceGrpc.cs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DysonNetwork.Pass.Localization;
|
||||||
|
using DysonNetwork.Shared;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Realm;
|
||||||
|
|
||||||
|
public class RealmServiceGrpc(
|
||||||
|
AppDatabase db,
|
||||||
|
RingService.RingServiceClient pusher,
|
||||||
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
|
: Shared.Proto.RealmService.RealmServiceBase
|
||||||
|
{
|
||||||
|
private const string CacheKeyPrefix = "account:realms:";
|
||||||
|
|
||||||
|
public override async Task<Shared.Proto.Realm> GetRealm(GetRealmRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var realm = request.QueryCase switch
|
||||||
|
{
|
||||||
|
GetRealmRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id) => await db.Realms.FindAsync(
|
||||||
|
Guid.Parse(request.Id)),
|
||||||
|
GetRealmRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug) => await db.Realms
|
||||||
|
.FirstOrDefaultAsync(r => r.Slug == request.Slug),
|
||||||
|
_ => throw new RpcException(new Status(StatusCode.InvalidArgument, "Must provide either id or slug"))
|
||||||
|
};
|
||||||
|
|
||||||
|
return realm == null
|
||||||
|
? throw new RpcException(new Status(StatusCode.NotFound, "Realm not found"))
|
||||||
|
: realm.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var ids = request.Ids.Select(Guid.Parse).ToList();
|
||||||
|
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
|
||||||
|
var response = new GetRealmBatchResponse();
|
||||||
|
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetUserRealmsResponse> GetUserRealms(GetUserRealmsRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{accountId}";
|
||||||
|
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
|
||||||
|
if (found && cachedRealms != null)
|
||||||
|
return new GetUserRealmsResponse { RealmIds = { cachedRealms.Select(g => g.ToString()) } };
|
||||||
|
|
||||||
|
var realms = await db.RealmMembers
|
||||||
|
.Include(m => m.Realm)
|
||||||
|
.Where(m => m.AccountId == accountId)
|
||||||
|
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||||
|
.Where(m => m.Realm != null)
|
||||||
|
.Select(m => m.Realm!.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Cache the result for 5 minutes
|
||||||
|
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
|
||||||
|
var response = new GetPublicRealmsResponse();
|
||||||
|
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var realms = await db.Realms
|
||||||
|
.Where(r => r.IsPublic)
|
||||||
|
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync();
|
||||||
|
var response = new GetPublicRealmsResponse();
|
||||||
|
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Empty> SendInviteNotify(SendInviteNotifyRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var member = request.Member;
|
||||||
|
var account = await db.Accounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Profile)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
|
||||||
|
|
||||||
|
if (account == null) throw new RpcException(new Status(StatusCode.NotFound, "Account not found"));
|
||||||
|
|
||||||
|
CultureService.SetCultureInfo(account.Language);
|
||||||
|
|
||||||
|
await pusher.SendPushNotificationToUserAsync(
|
||||||
|
new SendPushNotificationToUserRequest
|
||||||
|
{
|
||||||
|
UserId = account.Id.ToString(),
|
||||||
|
Notification = new PushNotification
|
||||||
|
{
|
||||||
|
Topic = "invites.realms",
|
||||||
|
Title = localizer["RealmInviteTitle"],
|
||||||
|
Body = localizer["RealmInviteBody", member.Realm?.Name ?? "Unknown Realm"],
|
||||||
|
ActionUri = "/realms",
|
||||||
|
IsSavable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<BoolValue> IsMemberWithRole(IsMemberWithRoleRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
if (request.RequiredRoles.Count == 0)
|
||||||
|
return new BoolValue { Value = false };
|
||||||
|
|
||||||
|
var maxRequiredRole = request.RequiredRoles.Max();
|
||||||
|
var member = await db.RealmMembers
|
||||||
|
.Where(m => m.RealmId == Guid.Parse(request.RealmId) && m.AccountId == Guid.Parse(request.AccountId) &&
|
||||||
|
m.JoinedAt != null && m.LeaveAt == null)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return new BoolValue { Value = member?.Role >= maxRequiredRole };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<RealmMember> LoadMemberAccount(LoadMemberAccountRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var member = request.Member;
|
||||||
|
var account = await db.Accounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Profile)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
|
||||||
|
|
||||||
|
var response = new RealmMember(member) { Account = account?.ToProtoValue() };
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<LoadMemberAccountsResponse> LoadMemberAccounts(LoadMemberAccountsRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountIds = request.Members.Select(m => Guid.Parse(m.AccountId)).ToList();
|
||||||
|
var accounts = await db.Accounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Profile)
|
||||||
|
.Where(a => accountIds.Contains(a.Id))
|
||||||
|
.ToDictionaryAsync(a => a.Id, a => a.ToProtoValue());
|
||||||
|
|
||||||
|
var response = new LoadMemberAccountsResponse();
|
||||||
|
foreach (var member in request.Members)
|
||||||
|
{
|
||||||
|
var updatedMember = new RealmMember(member);
|
||||||
|
if (accounts.TryGetValue(Guid.Parse(member.AccountId), out var account))
|
||||||
|
{
|
||||||
|
updatedMember.Account = account;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Members.Add(updatedMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
|
|||||||
using DysonNetwork.Pass.Credit;
|
using DysonNetwork.Pass.Credit;
|
||||||
using DysonNetwork.Pass.Leveling;
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using DysonNetwork.Pass.Realm;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
@@ -21,7 +22,6 @@ public static class ApplicationConfiguration
|
|||||||
app.ConfigureForwardedHeaders(configuration);
|
app.ConfigureForwardedHeaders(configuration);
|
||||||
|
|
||||||
app.UseWebSockets();
|
app.UseWebSockets();
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<PermissionMiddleware>();
|
app.UseMiddleware<PermissionMiddleware>();
|
||||||
@@ -42,6 +42,7 @@ public static class ApplicationConfiguration
|
|||||||
app.MapGrpcService<BotAccountReceiverGrpc>();
|
app.MapGrpcService<BotAccountReceiverGrpc>();
|
||||||
app.MapGrpcService<WalletServiceGrpc>();
|
app.MapGrpcService<WalletServiceGrpc>();
|
||||||
app.MapGrpcService<PaymentServiceGrpc>();
|
app.MapGrpcService<PaymentServiceGrpc>();
|
||||||
|
app.MapGrpcService<RealmServiceGrpc>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,32 +53,10 @@ public class BroadcastEventHandler(
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Handle subscription orders
|
// Handle subscription orders
|
||||||
if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
|
if (
|
||||||
{
|
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
|
||||||
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
|
evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
|
||||||
|
)
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Handle gift orders
|
|
||||||
else if (evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true)
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
|
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
|
||||||
|
|
||||||
@@ -102,9 +80,57 @@ public class BroadcastEventHandler(
|
|||||||
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
|
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
|
||||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
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 if (evt.ProductIdentifier == "lottery")
|
||||||
|
{
|
||||||
|
logger.LogInformation("Handling lottery order: {OrderId}", evt.OrderId);
|
||||||
|
|
||||||
|
await using var scope = serviceProvider.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
var lotteries = scope.ServiceProvider.GetRequiredService<Lotteries.LotteryService>();
|
||||||
|
|
||||||
|
var order = await db.PaymentOrders.FindAsync(
|
||||||
|
[evt.OrderId],
|
||||||
|
cancellationToken: stoppingToken
|
||||||
|
);
|
||||||
|
if (order == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
|
||||||
|
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await lotteries.HandleLotteryOrder(order);
|
||||||
|
|
||||||
|
logger.LogInformation("Lottery ticket for order {OrderId} created successfully.", evt.OrderId);
|
||||||
|
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Not a subscription or gift order, skip
|
// Not a subscription, gift, or lottery order, skip
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ public static class ScheduledJobsConfiguration
|
|||||||
.WithIntervalInHours(1)
|
.WithIntervalInHours(1)
|
||||||
.RepeatForever())
|
.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())
|
||||||
|
);
|
||||||
|
|
||||||
|
var lotteryDrawJob = new JobKey("LotteryDraw");
|
||||||
|
q.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity(lotteryDrawJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(lotteryDrawJob)
|
||||||
|
.WithIdentity("LotteryDrawTrigger")
|
||||||
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
});
|
});
|
||||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using DysonNetwork.Pass.Credit;
|
|||||||
using DysonNetwork.Pass.Handlers;
|
using DysonNetwork.Pass.Handlers;
|
||||||
using DysonNetwork.Pass.Leveling;
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Mailer;
|
using DysonNetwork.Pass.Mailer;
|
||||||
|
using DysonNetwork.Pass.Realm;
|
||||||
using DysonNetwork.Pass.Safety;
|
using DysonNetwork.Pass.Safety;
|
||||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
@@ -91,19 +92,6 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
|
||||||
{
|
|
||||||
opts.Window = TimeSpan.FromMinutes(1);
|
|
||||||
opts.PermitLimit = 120;
|
|
||||||
opts.QueueLimit = 2;
|
|
||||||
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
@@ -152,6 +140,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<SafetyService>();
|
services.AddScoped<SafetyService>();
|
||||||
services.AddScoped<SocialCreditService>();
|
services.AddScoped<SocialCreditService>();
|
||||||
services.AddScoped<ExperienceService>();
|
services.AddScoped<ExperienceService>();
|
||||||
|
services.AddScoped<RealmService>();
|
||||||
|
services.AddScoped<Lotteries.LotteryService>();
|
||||||
|
|
||||||
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||||
services.AddScoped<OidcProviderService>();
|
services.AddScoped<OidcProviderService>();
|
||||||
|
|||||||
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal file
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using DysonNetwork.Pass.Auth;
|
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,8 +8,54 @@ namespace DysonNetwork.Pass.Wallet;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/orders")]
|
[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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
|
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -21,6 +67,11 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
|
|||||||
return Ok(order);
|
return Ok(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PayOrderRequest
|
||||||
|
{
|
||||||
|
public string PinCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/pay")]
|
[HttpPost("{id:guid}/pay")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
|
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
|
||||||
@@ -47,9 +98,46 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
|
|||||||
return BadRequest(new { error = ex.Message });
|
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;
|
|
||||||
}
|
|
||||||
@@ -439,12 +439,346 @@ public class PaymentService(
|
|||||||
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
|
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,
|
payerWallet.Id,
|
||||||
payeeWallet.Id,
|
payeeWallet.Id,
|
||||||
currency,
|
currency,
|
||||||
amount,
|
amount,
|
||||||
$"Transfer from account {payerAccountId} to {payeeAccountId}",
|
$"Transfer from account {payerAccountId} to {payeeAccountId}",
|
||||||
Shared.Models.TransactionType.Transfer);
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,19 +146,6 @@ public class SubscriptionGiftController(
|
|||||||
{
|
{
|
||||||
error = "You already have an active subscription of this type.";
|
error = "You already have an active subscription of this type.";
|
||||||
}
|
}
|
||||||
else if (subscriptionInfo.RequiredLevel > 0)
|
|
||||||
{
|
|
||||||
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
|
|
||||||
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
|
|
||||||
{
|
|
||||||
error =
|
|
||||||
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
canRedeem = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
canRedeem = true;
|
canRedeem = true;
|
||||||
@@ -197,6 +184,8 @@ public class SubscriptionGiftController(
|
|||||||
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
|
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const int MinimumAccountLevel = 60;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Purchases a gift subscription.
|
/// Purchases a gift subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -206,6 +195,12 @@ public class SubscriptionGiftController(
|
|||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
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;
|
Duration? giftDuration = null;
|
||||||
if (request.GiftDurationDays.HasValue)
|
if (request.GiftDurationDays.HasValue)
|
||||||
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);
|
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);
|
||||||
|
|||||||
@@ -79,15 +79,6 @@ public class SubscriptionService(
|
|||||||
var couponData = await couponTask;
|
var couponData = await couponTask;
|
||||||
|
|
||||||
// Validation checks
|
// Validation checks
|
||||||
if (subscriptionInfo.RequiredLevel > 0)
|
|
||||||
{
|
|
||||||
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}."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFreeTrial && prevFreeTrial != null)
|
if (isFreeTrial && prevFreeTrial != null)
|
||||||
throw new InvalidOperationException("Free trial already exists.");
|
throw new InvalidOperationException("Free trial already exists.");
|
||||||
@@ -259,6 +250,14 @@ public class SubscriptionService(
|
|||||||
: null;
|
: null;
|
||||||
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
|
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(
|
return await payment.CreateOrderAsync(
|
||||||
null,
|
null,
|
||||||
subscriptionInfo.Currency,
|
subscriptionInfo.Currency,
|
||||||
@@ -662,33 +661,7 @@ public class SubscriptionService(
|
|||||||
db.WalletGifts.Add(gift);
|
db.WalletGifts.Add(gift);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Create order and process payment
|
gift.Gifter = gifter;
|
||||||
var order = await payment.CreateOrderAsync(
|
|
||||||
null, // No specific payee wallet for gifts
|
|
||||||
subscriptionInfo.Currency,
|
|
||||||
finalPrice,
|
|
||||||
appIdentifier: "gift",
|
|
||||||
productIdentifier: subscriptionIdentifier,
|
|
||||||
meta: new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["gift_id"] = gift.Id.ToString()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// If payment method is in-app wallet, process payment immediately
|
|
||||||
if (paymentMethod == SubscriptionPaymentMethod.InAppWallet)
|
|
||||||
{
|
|
||||||
var gifterWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == gifter.Id);
|
|
||||||
if (gifterWallet == null)
|
|
||||||
throw new InvalidOperationException("Gifter wallet not found.");
|
|
||||||
|
|
||||||
await payment.PayOrderAsync(order.Id, gifterWallet);
|
|
||||||
|
|
||||||
// Mark gift as sent after successful payment
|
|
||||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
|
|
||||||
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
return gift;
|
return gift;
|
||||||
}
|
}
|
||||||
@@ -719,6 +692,9 @@ public class SubscriptionService(
|
|||||||
if (now > gift.ExpiresAt)
|
if (now > gift.ExpiresAt)
|
||||||
throw new InvalidOperationException("Gift has expired.");
|
throw new InvalidOperationException("Gift has expired.");
|
||||||
|
|
||||||
|
if (gift.GifterId == redeemer.Id)
|
||||||
|
throw new InvalidOperationException("You cannot redeem your own gift.");
|
||||||
|
|
||||||
// Validate redeemer permissions
|
// Validate redeemer permissions
|
||||||
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
|
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
|
||||||
throw new InvalidOperationException("This gift is not intended for you.");
|
throw new InvalidOperationException("This gift is not intended for you.");
|
||||||
@@ -731,6 +707,56 @@ public class SubscriptionService(
|
|||||||
if (subscriptionInfo is null)
|
if (subscriptionInfo is null)
|
||||||
throw new InvalidOperationException("Invalid gift subscription type.");
|
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
|
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||||
? SubscriptionTypeData.SubscriptionDict
|
? SubscriptionTypeData.SubscriptionDict
|
||||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||||
@@ -742,17 +768,10 @@ public class SubscriptionService(
|
|||||||
if (existingSubscription is not null)
|
if (existingSubscription is not null)
|
||||||
throw new InvalidOperationException("You already have an active subscription of this type.");
|
throw new InvalidOperationException("You already have an active subscription of this type.");
|
||||||
|
|
||||||
// Check account level requirement
|
// We do not check account level requirement, since it is a gift
|
||||||
if (subscriptionInfo.RequiredLevel > 0)
|
|
||||||
{
|
|
||||||
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == redeemer.Id);
|
|
||||||
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the subscription from the gift
|
// Create the subscription from the gift
|
||||||
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription
|
var cycleDuration = Duration.FromDays(28);
|
||||||
var subscription = new SnWalletSubscription
|
var subscription = new SnWalletSubscription
|
||||||
{
|
{
|
||||||
BegunAt = now,
|
BegunAt = now,
|
||||||
@@ -761,7 +780,7 @@ public class SubscriptionService(
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsFreeTrial = false,
|
IsFreeTrial = false,
|
||||||
Status = Shared.Models.SubscriptionStatus.Active,
|
Status = Shared.Models.SubscriptionStatus.Active,
|
||||||
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption
|
PaymentMethod = "gift", // Special payment method indicating gift redemption
|
||||||
PaymentDetails = new Shared.Models.SnPaymentDetails
|
PaymentDetails = new Shared.Models.SnPaymentDetails
|
||||||
{
|
{
|
||||||
Currency = "gift",
|
Currency = "gift",
|
||||||
@@ -772,7 +791,6 @@ public class SubscriptionService(
|
|||||||
Coupon = gift.Coupon,
|
Coupon = gift.Coupon,
|
||||||
RenewalAt = now.Plus(cycleDuration),
|
RenewalAt = now.Plus(cycleDuration),
|
||||||
AccountId = redeemer.Id,
|
AccountId = redeemer.Id,
|
||||||
GiftId = gift.Id
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the gift status
|
// Update the gift status
|
||||||
@@ -783,18 +801,18 @@ public class SubscriptionService(
|
|||||||
gift.UpdatedAt = now;
|
gift.UpdatedAt = now;
|
||||||
|
|
||||||
// Save both gift and subscription
|
// Save both gift and subscription
|
||||||
using var transaction = await db.Database.BeginTransactionAsync();
|
using var createTransaction = await db.Database.BeginTransactionAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
db.WalletSubscriptions.Add(subscription);
|
db.WalletSubscriptions.Add(subscription);
|
||||||
db.WalletGifts.Update(gift);
|
db.WalletGifts.Update(gift);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await createTransaction.CommitAsync();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync();
|
await createTransaction.RollbackAsync();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Wallet;
|
namespace DysonNetwork.Pass.Wallet;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/wallets")]
|
[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]
|
[HttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -39,6 +42,72 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
|||||||
return Ok(wallet);
|
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")]
|
[HttpGet("transactions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
|
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
|
||||||
@@ -61,6 +130,12 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
|||||||
var transactions = await query
|
var transactions = await query
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.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();
|
.ToListAsync();
|
||||||
|
|
||||||
return Ok(transactions);
|
return Ok(transactions);
|
||||||
@@ -102,6 +177,15 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
|||||||
[Required] public Guid AccountId { get; set; }
|
[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")]
|
[HttpPost("balance")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
||||||
@@ -128,4 +212,190 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
|||||||
|
|
||||||
return Ok(transaction);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,15 @@ public class WebSocketController(
|
|||||||
{
|
{
|
||||||
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex,
|
logger.LogError(ex,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||||
<PackageReference Include="MailKit" Version="4.13.0" />
|
<PackageReference Include="MailKit" Version="4.13.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
|||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddDysonAuth();
|
builder.Services.AddDysonAuth();
|
||||||
builder.Services.AddAccountService();
|
builder.Services.AddAccountService();
|
||||||
@@ -45,6 +44,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
|
|||||||
// Configure gRPC
|
// Configure gRPC
|
||||||
app.ConfigureGrpcServices();
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
app.UseSwaggerManifest();
|
app.UseSwaggerManifest("DysonNetwork.Ring");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class RingServiceGrpc(
|
|||||||
return Task.FromResult(new Empty());
|
return Task.FromResult(new Empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ using System.Text.Json;
|
|||||||
using DysonNetwork.Ring.Email;
|
using DysonNetwork.Ring.Email;
|
||||||
using DysonNetwork.Ring.Notification;
|
using DysonNetwork.Ring.Notification;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Stream;
|
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using NATS.Client.Core;
|
using NATS.Client.Core;
|
||||||
using NATS.Client.JetStream;
|
|
||||||
using NATS.Client.JetStream.Models;
|
|
||||||
using NATS.Net;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Ring.Services;
|
namespace DysonNetwork.Ring.Services;
|
||||||
|
|
||||||
@@ -39,29 +35,19 @@ public class QueueBackgroundService(
|
|||||||
private async Task RunConsumerAsync(CancellationToken stoppingToken)
|
private async Task RunConsumerAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Queue consumer started");
|
logger.LogInformation("Queue consumer started");
|
||||||
var js = nats.CreateJetStreamContext();
|
|
||||||
|
|
||||||
await js.EnsureStreamCreated("pusher_events", [QueueName]);
|
await foreach (var msg in nats.SubscribeAsync<byte[]>(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken))
|
||||||
|
|
||||||
var consumer = await js.CreateOrUpdateConsumerAsync(
|
|
||||||
"pusher_events",
|
|
||||||
new ConsumerConfig(QueueGroup), // durable consumer
|
|
||||||
cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
|
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
|
||||||
if (message is not null)
|
if (message is not null)
|
||||||
{
|
{
|
||||||
await ProcessMessageAsync(msg, message, stoppingToken);
|
await ProcessMessageAsync(message, stoppingToken);
|
||||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
||||||
await msg.AckAsync(cancellationToken: stoppingToken); // Acknowledge invalid messages to avoid redelivery
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
@@ -72,12 +58,11 @@ public class QueueBackgroundService(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error in queue consumer");
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using NATS.Client.Core;
|
using NATS.Client.Core;
|
||||||
using NATS.Net;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Ring.Services;
|
namespace DysonNetwork.Ring.Services;
|
||||||
|
|
||||||
@@ -21,8 +20,7 @@ public class QueueService(INatsConnection nats)
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||||
var js = nats.CreateJetStreamContext();
|
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||||
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false)
|
public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false)
|
||||||
@@ -37,8 +35,7 @@ public class QueueService(INatsConnection nats)
|
|||||||
Data = JsonSerializer.Serialize(notification)
|
Data = JsonSerializer.Serialize(notification)
|
||||||
};
|
};
|
||||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||||
var js = nats.CreateJetStreamContext();
|
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||||
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public static class ApplicationConfiguration
|
|||||||
app.ConfigureForwardedHeaders(configuration);
|
app.ConfigureForwardedHeaders(configuration);
|
||||||
|
|
||||||
app.UseWebSockets();
|
app.UseWebSockets();
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -50,19 +50,6 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
|
||||||
{
|
|
||||||
opts.Window = TimeSpan.FromMinutes(1);
|
|
||||||
opts.PermitLimit = 120;
|
|
||||||
opts.QueueLimit = 2;
|
|
||||||
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
||||||
<PackageReference Include="NATS.Net" Version="2.6.8" />
|
<PackageReference Include="NATS.Net" Version="2.6.8" />
|
||||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
@@ -29,18 +29,27 @@
|
|||||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||||
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
|
||||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
|
||||||
|
<PackageReference Include="Aspire.NATS.Net" Version="9.5.1" />
|
||||||
|
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
|
||||||
|
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.5.1" />
|
||||||
|
|
||||||
|
<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.13.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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
|
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -117,10 +117,6 @@ public static class Extensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
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
|
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||||
app.MapHealthChecks(HealthEndpointPath);
|
app.MapHealthChecks(HealthEndpointPath);
|
||||||
@@ -130,7 +126,6 @@ public static class Extensions
|
|||||||
{
|
{
|
||||||
Predicate = r => r.Tags.Contains("live")
|
Predicate = r => r.Tags.Contains("live")
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ public static class SwaggerGen
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static WebApplication UseSwaggerManifest(this WebApplication app)
|
public static WebApplication UseSwaggerManifest(this WebApplication app, string serviceName)
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ public static class SwaggerGen
|
|||||||
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
|
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
|
||||||
options.SwaggerEndpoint(
|
options.SwaggerEndpoint(
|
||||||
$"{publicBasePath}/swagger/v1/swagger.json",
|
$"{publicBasePath}/swagger/v1/swagger.json",
|
||||||
"Develop API v1");
|
$"{serviceName} API v1");
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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 class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
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? TimeZone { get; set; }
|
||||||
[MaxLength(1024)] public string? Location { get; set; }
|
[MaxLength(1024)] public string? Location { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public List<ProfileLink>? Links { 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? Birthday { get; set; }
|
||||||
public Instant? LastSeenAt { get; set; }
|
public Instant? LastSeenAt { get; set; }
|
||||||
|
|
||||||
@@ -209,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
|||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
Verification = Verification?.ToProtoValue(),
|
Verification = Verification?.ToProtoValue(),
|
||||||
ActiveBadge = ActiveBadge?.ToProtoValue(),
|
ActiveBadge = ActiveBadge?.ToProtoValue(),
|
||||||
|
UsernameColor = UsernameColor?.ToProtoValue(),
|
||||||
CreatedAt = CreatedAt.ToTimestamp(),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||||
};
|
};
|
||||||
@@ -238,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
|||||||
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
|
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||||
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
|
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||||
AccountId = Guid.Parse(proto.AccountId),
|
AccountId = Guid.Parse(proto.AccountId),
|
||||||
|
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
|
||||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||||
};
|
};
|
||||||
|
|||||||
19
DysonNetwork.Shared/Models/ActivityHeatmap.cs
Normal file
19
DysonNetwork.Shared/Models/ActivityHeatmap.cs
Normal 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; }
|
||||||
|
}
|
||||||
15
DysonNetwork.Shared/Models/Autocompletion.cs
Normal file
15
DysonNetwork.Shared/Models/Autocompletion.cs
Normal 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!;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
|
|||||||
[JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>();
|
[JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>();
|
||||||
|
|
||||||
public Guid? RealmId { get; set; }
|
public Guid? RealmId { get; set; }
|
||||||
public SnRealm? Realm { get; set; }
|
[NotMapped] public SnRealm? Realm { get; set; }
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
[JsonPropertyName("members")]
|
[JsonPropertyName("members")]
|
||||||
|
|||||||
@@ -81,12 +81,14 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public SnCustomApp FromProtoValue(Proto.CustomApp p)
|
public static SnCustomApp FromProtoValue(Proto.CustomApp p)
|
||||||
{
|
{
|
||||||
Id = Guid.Parse(p.Id);
|
var obj = new SnCustomApp
|
||||||
Slug = p.Slug;
|
{
|
||||||
Name = p.Name;
|
Id = Guid.Parse(p.Id),
|
||||||
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description;
|
Slug = p.Slug,
|
||||||
|
Name = p.Name,
|
||||||
|
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description,
|
||||||
Status = p.Status switch
|
Status = p.Status switch
|
||||||
{
|
{
|
||||||
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
|
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
|
||||||
@@ -94,23 +96,26 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
|||||||
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
|
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
|
||||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||||
_ => CustomAppStatus.Developing
|
_ => 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();
|
if (p.Picture is not null) obj.Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
if (p.Background is not null) obj.Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
|
||||||
if (p.Picture is not null) Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
|
if (p.Verification is not null) obj.Verification = SnVerificationMark.FromProtoValue(p.Verification);
|
||||||
if (p.Background is not null) Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
|
|
||||||
if (p.Verification is not null) Verification = SnVerificationMark.FromProtoValue(p.Verification);
|
|
||||||
if (p.Links is not null)
|
if (p.Links is not null)
|
||||||
{
|
{
|
||||||
Links = new SnCustomAppLinks
|
obj.Links = new SnCustomAppLinks
|
||||||
{
|
{
|
||||||
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||||
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||||
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this;
|
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
|
|||||||
public SnPost? ForwardedPost { get; set; }
|
public SnPost? ForwardedPost { get; set; }
|
||||||
|
|
||||||
public Guid? RealmId { get; set; }
|
public Guid? RealmId { get; set; }
|
||||||
public SnRealm? Realm { get; set; }
|
[NotMapped] public SnRealm? Realm { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = [];
|
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = [];
|
||||||
|
|
||||||
@@ -73,11 +73,12 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
|
|||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
public SnPublisher Publisher { get; set; } = null!;
|
public SnPublisher Publisher { get; set; } = null!;
|
||||||
|
|
||||||
public ICollection<SnPostAward> Awards { get; set; } = null!;
|
public List<SnPostAward> Awards { get; set; } = null!;
|
||||||
[JsonIgnore] public ICollection<SnPostReaction> Reactions { get; set; } = new List<SnPostReaction>();
|
[JsonIgnore] public List<SnPostReaction> Reactions { get; set; } = [];
|
||||||
public ICollection<SnPostTag> Tags { get; set; } = new List<SnPostTag>();
|
public List<SnPostTag> Tags { get; set; } = [];
|
||||||
public ICollection<SnPostCategory> Categories { get; set; } = new List<SnPostCategory>();
|
public List<SnPostCategory> Categories { get; set; } = [];
|
||||||
[JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = new List<SnPostCollection>();
|
[JsonIgnore] public List<SnPostCollection> Collections { get; set; } = [];
|
||||||
|
public List<SnPostFeaturedRecord> FeaturedRecords { get; set; } = [];
|
||||||
|
|
||||||
[JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
|
[JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
|
||||||
[NotMapped] public bool IsTruncated { get; set; } = false;
|
[NotMapped] public bool IsTruncated { get; set; } = false;
|
||||||
@@ -104,7 +105,7 @@ public class SnPostTag : ModelBase
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||||
[MaxLength(256)] public string? Name { get; set; }
|
[MaxLength(256)] public string? Name { get; set; }
|
||||||
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>();
|
[JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
|
||||||
|
|
||||||
[NotMapped] public int? Usage { get; set; }
|
[NotMapped] public int? Usage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ public class SnPostCategory : ModelBase
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||||
[MaxLength(256)] public string? Name { get; set; }
|
[MaxLength(256)] public string? Name { get; set; }
|
||||||
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>();
|
[JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
|
||||||
|
|
||||||
[NotMapped] public int? Usage { get; set; }
|
[NotMapped] public int? Usage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -139,15 +140,14 @@ public class SnPostCollection : ModelBase
|
|||||||
|
|
||||||
public SnPublisher Publisher { get; set; } = null!;
|
public SnPublisher Publisher { get; set; } = null!;
|
||||||
|
|
||||||
public ICollection<SnPost> Posts { get; set; } = new List<SnPost>();
|
public List<SnPost> Posts { get; set; } = new List<SnPost>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SnPostFeaturedRecord : ModelBase
|
public class SnPostFeaturedRecord : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
public Guid PostId { get; set; }
|
public Guid PostId { get; set; }
|
||||||
public SnPost Post { get; set; } = null!;
|
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
||||||
public Instant? FeaturedAt { get; set; }
|
public Instant? FeaturedAt { get; set; }
|
||||||
public int SocialCredits { get; set; }
|
public int SocialCredits { get; set; }
|
||||||
}
|
}
|
||||||
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
|
|||||||
public Guid PostId { get; set; }
|
public Guid PostId { get; set; }
|
||||||
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
[NotMapped] public SnAccount? Account { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SnPostAward : ModelBase
|
public class SnPostAward : ModelBase
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class SnPublisher : ModelBase, IIdentifiedResource
|
|||||||
|
|
||||||
public Guid? AccountId { get; set; }
|
public Guid? AccountId { get; set; }
|
||||||
public Guid? RealmId { get; set; }
|
public Guid? RealmId { get; set; }
|
||||||
[JsonIgnore] public SnRealm? Realm { get; set; }
|
[NotMapped] public SnRealm? Realm { get; set; }
|
||||||
[NotMapped] public SnAccount? Account { get; set; }
|
[NotMapped] public SnAccount? Account { get; set; }
|
||||||
|
|
||||||
public string ResourceIdentifier => $"publisher:{Id}";
|
public string ResourceIdentifier => $"publisher:{Id}";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Shared.Models;
|
namespace DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
@@ -26,11 +28,35 @@ public class SnRealm : ModelBase, IIdentifiedResource
|
|||||||
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
|
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
|
[JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
|
||||||
[JsonIgnore] public ICollection<SnChatRoom> ChatRooms { get; set; } = new List<SnChatRoom>();
|
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
public string ResourceIdentifier => $"realm:{Id}";
|
public string ResourceIdentifier => $"realm:{Id}";
|
||||||
|
|
||||||
|
public Realm ToProtoValue()
|
||||||
|
{
|
||||||
|
return new Realm
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Name = Name,
|
||||||
|
Slug = Slug,
|
||||||
|
IsCommunity = IsCommunity,
|
||||||
|
IsPublic = IsPublic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SnRealm FromProtoValue(Realm proto)
|
||||||
|
{
|
||||||
|
return new SnRealm
|
||||||
|
{
|
||||||
|
Id = Guid.Parse(proto.Id),
|
||||||
|
Name = proto.Name,
|
||||||
|
Slug = proto.Slug,
|
||||||
|
Description = "",
|
||||||
|
IsCommunity = proto.IsCommunity,
|
||||||
|
IsPublic = proto.IsPublic
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract class RealmMemberRole
|
public abstract class RealmMemberRole
|
||||||
@@ -51,4 +77,40 @@ public class SnRealmMember : ModelBase
|
|||||||
public int Role { get; set; } = RealmMemberRole.Normal;
|
public int Role { get; set; } = RealmMemberRole.Normal;
|
||||||
public Instant? JoinedAt { get; set; }
|
public Instant? JoinedAt { get; set; }
|
||||||
public Instant? LeaveAt { get; set; }
|
public Instant? LeaveAt { get; set; }
|
||||||
|
|
||||||
|
public Proto.RealmMember ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Proto.RealmMember
|
||||||
|
{
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
RealmId = RealmId.ToString(),
|
||||||
|
Role = Role,
|
||||||
|
JoinedAt = JoinedAt?.ToTimestamp(),
|
||||||
|
LeaveAt = LeaveAt?.ToTimestamp(),
|
||||||
|
Realm = Realm.ToProtoValue()
|
||||||
|
};
|
||||||
|
if (Account != null)
|
||||||
|
{
|
||||||
|
proto.Account = Account.ToProtoValue();
|
||||||
|
}
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SnRealmMember FromProtoValue(RealmMember proto)
|
||||||
|
{
|
||||||
|
var member = new SnRealmMember
|
||||||
|
{
|
||||||
|
AccountId = Guid.Parse(proto.AccountId),
|
||||||
|
RealmId = Guid.Parse(proto.RealmId),
|
||||||
|
Role = proto.Role,
|
||||||
|
JoinedAt = proto.JoinedAt?.ToInstant(),
|
||||||
|
LeaveAt = proto.LeaveAt?.ToInstant(),
|
||||||
|
Realm = proto.Realm != null ? SnRealm.FromProtoValue(proto.Realm) : new SnRealm() // Provide default or handle null
|
||||||
|
};
|
||||||
|
if (proto.Account != null)
|
||||||
|
{
|
||||||
|
member.Account = SnAccount.FromProtoValue(proto.Account);
|
||||||
|
}
|
||||||
|
return member;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
49
DysonNetwork.Shared/Models/SnLottery.cs
Normal file
49
DysonNetwork.Shared/Models/SnLottery.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
public enum LotteryDrawStatus
|
||||||
|
{
|
||||||
|
Pending = 0,
|
||||||
|
Drawn = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SnLotteryRecord : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public Instant DrawDate { get; set; } // Date of the draw
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public List<int> WinningRegionOneNumbers { get; set; } = new(); // 5 winning numbers
|
||||||
|
|
||||||
|
[Range(0, 99)]
|
||||||
|
public int WinningRegionTwoNumber { get; set; } // 1 winning number
|
||||||
|
|
||||||
|
public int TotalTickets { get; set; } // Total tickets processed for this draw
|
||||||
|
public int TotalPrizesAwarded { get; set; } // Total prizes awarded
|
||||||
|
public long TotalPrizeAmount { get; set; } // Total ISP prize amount awarded
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SnLottery : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public SnAccount Account { get; set; } = null!;
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public List<int> RegionOneNumbers { get; set; } = new(); // 5 numbers, 0-99, unique
|
||||||
|
|
||||||
|
[Range(0, 99)]
|
||||||
|
public int RegionTwoNumber { get; set; } // 1 number, 0-99, can repeat
|
||||||
|
|
||||||
|
public int Multiplier { get; set; } = 1; // Default 1x
|
||||||
|
|
||||||
|
public LotteryDrawStatus DrawStatus { get; set; } = LotteryDrawStatus.Pending; // Status to track draw processing
|
||||||
|
|
||||||
|
public Instant? DrawDate { get; set; } // Date when this ticket was drawn
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
@@ -30,21 +31,21 @@ public record class SubscriptionTypeData(
|
|||||||
SubscriptionType.StellarProgram,
|
SubscriptionType.StellarProgram,
|
||||||
WalletCurrency.SourcePoint,
|
WalletCurrency.SourcePoint,
|
||||||
1200,
|
1200,
|
||||||
3
|
20
|
||||||
),
|
),
|
||||||
[SubscriptionType.Nova] = new SubscriptionTypeData(
|
[SubscriptionType.Nova] = new SubscriptionTypeData(
|
||||||
SubscriptionType.Nova,
|
SubscriptionType.Nova,
|
||||||
SubscriptionType.StellarProgram,
|
SubscriptionType.StellarProgram,
|
||||||
WalletCurrency.SourcePoint,
|
WalletCurrency.SourcePoint,
|
||||||
2400,
|
2400,
|
||||||
6
|
40
|
||||||
),
|
),
|
||||||
[SubscriptionType.Supernova] = new SubscriptionTypeData(
|
[SubscriptionType.Supernova] = new SubscriptionTypeData(
|
||||||
SubscriptionType.Supernova,
|
SubscriptionType.Supernova,
|
||||||
SubscriptionType.StellarProgram,
|
SubscriptionType.StellarProgram,
|
||||||
WalletCurrency.SourcePoint,
|
WalletCurrency.SourcePoint,
|
||||||
3600,
|
3600,
|
||||||
9
|
60
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +129,8 @@ public class SnWalletGift : ModelBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The subscription created when the gift is redeemed.
|
/// The subscription created when the gift is redeemed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SnWalletSubscription? Subscription { get; set; }
|
[JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
|
||||||
|
public Guid? SubscriptionId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When the gift expires and can no longer be redeemed.
|
/// When the gift expires and can no longer be redeemed.
|
||||||
@@ -337,7 +339,6 @@ public class SnWalletSubscription : ModelBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// If this subscription was redeemed from a gift, this references the gift record.
|
/// If this subscription was redeemed from a gift, this references the gift record.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? GiftId { get; set; }
|
|
||||||
public SnWalletGift? Gift { get; set; }
|
public SnWalletGift? Gift { get; set; }
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Shared.Models;
|
namespace DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
@@ -62,3 +64,96 @@ public class SnWalletPocket : ModelBase
|
|||||||
WalletId = Guid.Parse(proto.WalletId),
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ message AccountStatus {
|
|||||||
bytes meta = 10;
|
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
|
// Profile contains detailed information about a user
|
||||||
message AccountProfile {
|
message AccountProfile {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
@@ -89,6 +96,7 @@ message AccountProfile {
|
|||||||
|
|
||||||
google.protobuf.Timestamp created_at = 22;
|
google.protobuf.Timestamp created_at = 22;
|
||||||
google.protobuf.Timestamp updated_at = 23;
|
google.protobuf.Timestamp updated_at = 23;
|
||||||
|
optional UsernameColor username_color = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountContact represents a contact method for an account
|
// AccountContact represents a contact method for an account
|
||||||
@@ -254,6 +262,7 @@ service AccountService {
|
|||||||
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||||
|
rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
|
||||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
||||||
|
|
||||||
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
|
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
|
||||||
@@ -343,6 +352,10 @@ message LookupAccountBatchRequest {
|
|||||||
repeated string names = 1;
|
repeated string names = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message SearchAccountRequest {
|
||||||
|
string query = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message GetAccountBatchResponse {
|
message GetAccountBatchResponse {
|
||||||
repeated Account accounts = 1; // List of accounts
|
repeated Account accounts = 1; // List of accounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ message BotAccount {
|
|||||||
message CreateBotAccountRequest {
|
message CreateBotAccountRequest {
|
||||||
Account account = 1;
|
Account account = 1;
|
||||||
string automated_id = 2;
|
string automated_id = 2;
|
||||||
optional string picture_id = 8;
|
google.protobuf.StringValue picture_id = 8;
|
||||||
optional string background_id = 9;
|
google.protobuf.StringValue background_id = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateBotAccountResponse {
|
message CreateBotAccountResponse {
|
||||||
|
|||||||
110
DysonNetwork.Shared/Proto/realm.proto
Normal file
110
DysonNetwork.Shared/Proto/realm.proto
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package proto;
|
||||||
|
|
||||||
|
option csharp_namespace = "DysonNetwork.Shared.Proto";
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "google/protobuf/wrappers.proto";
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
import 'account.proto';
|
||||||
|
|
||||||
|
// Message Definitions
|
||||||
|
|
||||||
|
message Realm {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string slug = 3;
|
||||||
|
bool is_community = 4;
|
||||||
|
bool is_public = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RealmMember {
|
||||||
|
string account_id = 1;
|
||||||
|
string realm_id = 2;
|
||||||
|
int32 role = 3;
|
||||||
|
optional google.protobuf.Timestamp joined_at = 4;
|
||||||
|
optional google.protobuf.Timestamp leave_at = 5;
|
||||||
|
optional Account account = 6;
|
||||||
|
optional Realm realm = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service Definitions
|
||||||
|
|
||||||
|
service RealmService {
|
||||||
|
// Get realm by id or slug
|
||||||
|
rpc GetRealm(GetRealmRequest) returns (Realm) {}
|
||||||
|
// Get realm batch by ids
|
||||||
|
rpc GetRealmBatch(GetRealmBatchRequest) returns (GetRealmBatchResponse) {}
|
||||||
|
// Get realms for a user
|
||||||
|
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
|
||||||
|
// Get public realms
|
||||||
|
rpc GetPublicRealms(google.protobuf.Empty) returns (GetPublicRealmsResponse) {}
|
||||||
|
// Search public realms
|
||||||
|
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
|
||||||
|
// Send invitation notification
|
||||||
|
rpc SendInviteNotify(SendInviteNotifyRequest) returns (google.protobuf.Empty) {}
|
||||||
|
// Check if member has required role
|
||||||
|
rpc IsMemberWithRole(IsMemberWithRoleRequest) returns (google.protobuf.BoolValue) {}
|
||||||
|
// Load account for a member
|
||||||
|
rpc LoadMemberAccount(LoadMemberAccountRequest) returns (RealmMember) {}
|
||||||
|
// Load accounts for members
|
||||||
|
rpc LoadMemberAccounts(LoadMemberAccountsRequest) returns (LoadMemberAccountsResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request/Response Messages
|
||||||
|
|
||||||
|
message GetRealmRequest {
|
||||||
|
oneof query {
|
||||||
|
string id = 1;
|
||||||
|
string slug = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUserRealmsRequest {
|
||||||
|
string account_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRealmBatchRequest {
|
||||||
|
repeated string ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRealmBatchResponse {
|
||||||
|
repeated Realm realms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUserRealmsResponse {
|
||||||
|
repeated string realm_ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPublicRealmsResponse {
|
||||||
|
repeated Realm realms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchRealmsRequest {
|
||||||
|
string query = 1;
|
||||||
|
int32 limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SendInviteNotifyRequest {
|
||||||
|
RealmMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message IsMemberWithRoleRequest {
|
||||||
|
string realm_id = 1;
|
||||||
|
string account_id = 2;
|
||||||
|
repeated int32 required_roles = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoadMemberAccountRequest {
|
||||||
|
RealmMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoadMemberAccountsRequest {
|
||||||
|
repeated RealmMember members = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoadMemberAccountsResponse {
|
||||||
|
repeated RealmMember members = 1;
|
||||||
|
}
|
||||||
@@ -22,6 +22,42 @@ message WalletPocket {
|
|||||||
string wallet_id = 4;
|
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 {
|
enum SubscriptionStatus {
|
||||||
// Using proto3 enum naming convention
|
// Using proto3 enum naming convention
|
||||||
SUBSCRIPTION_STATUS_UNSPECIFIED = 0;
|
SUBSCRIPTION_STATUS_UNSPECIFIED = 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using DysonNetwork.Shared.Proto;
|
|||||||
|
|
||||||
namespace DysonNetwork.Shared.Registry;
|
namespace DysonNetwork.Shared.Registry;
|
||||||
|
|
||||||
public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
public class RemoteAccountService(AccountService.AccountServiceClient accounts)
|
||||||
{
|
{
|
||||||
public async Task<Account> GetAccount(Guid id)
|
public async Task<Account> GetAccount(Guid id)
|
||||||
{
|
{
|
||||||
@@ -27,6 +27,13 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
|||||||
return response.Accounts.ToList();
|
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)
|
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
|
||||||
{
|
{
|
||||||
var request = new GetBotAccountBatchRequest();
|
var request = new GetBotAccountBatchRequest();
|
||||||
82
DysonNetwork.Shared/Registry/RemoteRealmService.cs
Normal file
82
DysonNetwork.Shared/Registry/RemoteRealmService.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Shared.Registry;
|
||||||
|
|
||||||
|
public class RemoteRealmService(RealmService.RealmServiceClient realms)
|
||||||
|
{
|
||||||
|
public async Task<SnRealm> GetRealm(string id)
|
||||||
|
{
|
||||||
|
var request = new GetRealmRequest { Id = id };
|
||||||
|
var response = await realms.GetRealmAsync(request);
|
||||||
|
return SnRealm.FromProtoValue(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnRealm> GetRealmBySlug(string slug)
|
||||||
|
{
|
||||||
|
var request = new GetRealmRequest { Slug = slug };
|
||||||
|
var response = await realms.GetRealmAsync(request);
|
||||||
|
return SnRealm.FromProtoValue(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> GetUserRealms(Guid accountId)
|
||||||
|
{
|
||||||
|
var request = new GetUserRealmsRequest { AccountId = accountId.ToString() };
|
||||||
|
var response = await realms.GetUserRealmsAsync(request);
|
||||||
|
return response.RealmIds.Select(Guid.Parse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnRealm>> GetPublicRealms()
|
||||||
|
{
|
||||||
|
var response = await realms.GetPublicRealmsAsync(new Empty());
|
||||||
|
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnRealm>> SearchRealms(string query, int limit)
|
||||||
|
{
|
||||||
|
var request = new SearchRealmsRequest { Query = query, Limit = limit };
|
||||||
|
var response = await realms.SearchRealmsAsync(request);
|
||||||
|
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnRealm>> GetRealmBatch(List<string> ids)
|
||||||
|
{
|
||||||
|
var request = new GetRealmBatchRequest();
|
||||||
|
request.Ids.AddRange(ids);
|
||||||
|
var response = await realms.GetRealmBatchAsync(request);
|
||||||
|
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendInviteNotify(SnRealmMember member)
|
||||||
|
{
|
||||||
|
var protoMember = member.ToProtoValue();
|
||||||
|
var request = new SendInviteNotifyRequest { Member = protoMember };
|
||||||
|
await realms.SendInviteNotifyAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, List<int> requiredRoles)
|
||||||
|
{
|
||||||
|
var request = new IsMemberWithRoleRequest { RealmId = realmId.ToString(), AccountId = accountId.ToString() };
|
||||||
|
request.RequiredRoles.AddRange(requiredRoles);
|
||||||
|
var response = await realms.IsMemberWithRoleAsync(request);
|
||||||
|
return response.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
|
||||||
|
{
|
||||||
|
var protoMember = member.ToProtoValue();
|
||||||
|
var request = new LoadMemberAccountRequest { Member = protoMember };
|
||||||
|
var response = await realms.LoadMemberAccountAsync(request);
|
||||||
|
return SnRealmMember.FromProtoValue(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnRealmMember>> LoadMemberAccounts(List<SnRealmMember> members)
|
||||||
|
{
|
||||||
|
var protoMembers = members.Select(m => m.ToProtoValue()).ToList();
|
||||||
|
var request = new LoadMemberAccountsRequest();
|
||||||
|
request.Members.AddRange(protoMembers);
|
||||||
|
var response = await realms.LoadMemberAccountsAsync(request);
|
||||||
|
return response.Members.Select(SnRealmMember.FromProtoValue).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,11 +36,11 @@ public static class ServiceInjectionHelper
|
|||||||
public static IServiceCollection AddAccountService(this IServiceCollection services)
|
public static IServiceCollection AddAccountService(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services
|
services
|
||||||
.AddGrpcClient<AccountService.AccountServiceClient>(o => o.Address = new Uri("https://_grpc.pass") )
|
.AddGrpcClient<AccountService.AccountServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
services.AddSingleton<AccountClientHelper>();
|
services.AddSingleton<RemoteAccountService>();
|
||||||
|
|
||||||
services
|
services
|
||||||
.AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o =>
|
.AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o =>
|
||||||
@@ -60,6 +60,13 @@ public static class ServiceInjectionHelper
|
|||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddGrpcClient<RealmService.RealmServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||||
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
|
);
|
||||||
|
services.AddSingleton<RemoteRealmService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +77,8 @@ public static class ServiceInjectionHelper
|
|||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
|
|
||||||
services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o => o.Address = new Uri("https://_grpc.drive"))
|
services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o =>
|
||||||
|
o.Address = new Uri("https://_grpc.drive"))
|
||||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
@@ -80,7 +88,8 @@ public static class ServiceInjectionHelper
|
|||||||
|
|
||||||
public static IServiceCollection AddPublisherService(this IServiceCollection services)
|
public static IServiceCollection AddPublisherService(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere"))
|
services
|
||||||
|
.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere"))
|
||||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
@@ -90,7 +99,8 @@ public static class ServiceInjectionHelper
|
|||||||
|
|
||||||
public static IServiceCollection AddDevelopService(this IServiceCollection services)
|
public static IServiceCollection AddDevelopService(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o => o.Address = new Uri("https://_grpc.develop"))
|
services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o =>
|
||||||
|
o.Address = new Uri("https://_grpc.develop"))
|
||||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
using DysonNetwork.Sphere.Discovery;
|
using DysonNetwork.Sphere.Discovery;
|
||||||
using DysonNetwork.Sphere.Post;
|
using DysonNetwork.Sphere.Post;
|
||||||
using DysonNetwork.Sphere.Realm;
|
|
||||||
using DysonNetwork.Sphere.WebReader;
|
using DysonNetwork.Sphere.WebReader;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -13,7 +13,7 @@ public class ActivityService(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
Publisher.PublisherService pub,
|
Publisher.PublisherService pub,
|
||||||
PostService ps,
|
PostService ps,
|
||||||
RealmService rs,
|
RemoteRealmService rs,
|
||||||
DiscoveryService ds,
|
DiscoveryService ds,
|
||||||
AccountService.AccountServiceClient accounts
|
AccountService.AccountServiceClient accounts
|
||||||
)
|
)
|
||||||
@@ -40,19 +40,15 @@ public class ActivityService(
|
|||||||
debugInclude ??= new HashSet<string>();
|
debugInclude ??= new HashSet<string>();
|
||||||
|
|
||||||
// Get and process posts
|
// Get and process posts
|
||||||
var postsQuery = db.Posts
|
var publicRealms = await rs.GetPublicRealms();
|
||||||
.Include(e => e.RepliedPost)
|
var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
|
||||||
.Include(e => e.ForwardedPost)
|
|
||||||
.Include(e => e.Categories)
|
var postsQuery = BuildPostsQuery(cursor, null, publicRealmIds)
|
||||||
.Include(e => e.Tags)
|
|
||||||
.Include(e => e.Realm)
|
|
||||||
.Where(e => e.RepliedPostId == null)
|
|
||||||
.Where(p => cursor == null || p.PublishedAt < cursor)
|
|
||||||
.OrderByDescending(p => p.PublishedAt)
|
|
||||||
.FilterWithVisibility(null, [], [], isListing: true)
|
.FilterWithVisibility(null, [], [], isListing: true)
|
||||||
.Take(take * 5);
|
.Take(take * 5);
|
||||||
|
|
||||||
var posts = await GetAndProcessPosts(postsQuery);
|
var posts = await GetAndProcessPosts(postsQuery);
|
||||||
|
await LoadPostsRealmsAsync(posts, rs);
|
||||||
posts = RankPosts(posts, take);
|
posts = RankPosts(posts, take);
|
||||||
|
|
||||||
var interleaved = new List<SnActivity>();
|
var interleaved = new List<SnActivity>();
|
||||||
@@ -102,7 +98,7 @@ public class ActivityService(
|
|||||||
|
|
||||||
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
|
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
// Build and execute the posts query
|
// Build and execute the post query
|
||||||
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
|
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
|
||||||
|
|
||||||
// Apply visibility filtering and execute
|
// Apply visibility filtering and execute
|
||||||
@@ -118,10 +114,10 @@ public class ActivityService(
|
|||||||
var posts = await GetAndProcessPosts(
|
var posts = await GetAndProcessPosts(
|
||||||
postsQuery,
|
postsQuery,
|
||||||
currentUser,
|
currentUser,
|
||||||
userFriends,
|
|
||||||
userPublishers,
|
|
||||||
trackViews: true);
|
trackViews: true);
|
||||||
|
|
||||||
|
await LoadPostsRealmsAsync(posts, rs);
|
||||||
|
|
||||||
posts = RankPosts(posts, take);
|
posts = RankPosts(posts, take);
|
||||||
|
|
||||||
var interleaved = new List<SnActivity>();
|
var interleaved = new List<SnActivity>();
|
||||||
@@ -219,15 +215,19 @@ public class ActivityService(
|
|||||||
|
|
||||||
private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5)
|
private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5)
|
||||||
{
|
{
|
||||||
|
var publicRealms = await rs.GetPublicRealms();
|
||||||
|
var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
|
||||||
|
|
||||||
var postsQuery = db.Posts
|
var postsQuery = db.Posts
|
||||||
.Include(p => p.Categories)
|
.Include(p => p.Categories)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Realm)
|
|
||||||
.Where(p => p.RepliedPostId == null)
|
.Where(p => p.RepliedPostId == null)
|
||||||
|
.Where(p => p.RealmId == null || publicRealmIds.Contains(p.RealmId.Value))
|
||||||
.OrderBy(_ => EF.Functions.Random())
|
.OrderBy(_ => EF.Functions.Random())
|
||||||
.Take(count);
|
.Take(count);
|
||||||
|
|
||||||
var posts = await GetAndProcessPosts(postsQuery, trackViews: false);
|
var posts = await GetAndProcessPosts(postsQuery, trackViews: false);
|
||||||
|
await LoadPostsRealmsAsync(posts, rs);
|
||||||
|
|
||||||
return posts.Count == 0
|
return posts.Count == 0
|
||||||
? null
|
? null
|
||||||
@@ -272,8 +272,6 @@ public class ActivityService(
|
|||||||
private async Task<List<SnPost>> GetAndProcessPosts(
|
private async Task<List<SnPost>> GetAndProcessPosts(
|
||||||
IQueryable<SnPost> baseQuery,
|
IQueryable<SnPost> baseQuery,
|
||||||
Account? currentUser = null,
|
Account? currentUser = null,
|
||||||
List<Guid>? userFriends = null,
|
|
||||||
List<Shared.Models.SnPublisher>? userPublishers = null,
|
|
||||||
bool trackViews = true)
|
bool trackViews = true)
|
||||||
{
|
{
|
||||||
var posts = await baseQuery.ToListAsync();
|
var posts = await baseQuery.ToListAsync();
|
||||||
@@ -306,7 +304,7 @@ public class ActivityService(
|
|||||||
.Include(e => e.ForwardedPost)
|
.Include(e => e.ForwardedPost)
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.Include(e => e.Realm)
|
.Include(e => e.FeaturedRecords)
|
||||||
.Where(e => e.RepliedPostId == null)
|
.Where(e => e.RepliedPostId == null)
|
||||||
.Where(p => cursor == null || p.PublishedAt < cursor)
|
.Where(p => cursor == null || p.PublishedAt < cursor)
|
||||||
.OrderByDescending(p => p.PublishedAt)
|
.OrderByDescending(p => p.PublishedAt)
|
||||||
@@ -315,10 +313,14 @@ public class ActivityService(
|
|||||||
if (filteredPublishersId != null && filteredPublishersId.Count != 0)
|
if (filteredPublishersId != null && filteredPublishersId.Count != 0)
|
||||||
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
|
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
|
||||||
if (userRealms == null)
|
if (userRealms == null)
|
||||||
query = query.Where(p => p.Realm == null || p.Realm.IsPublic);
|
{
|
||||||
|
// For anonymous users, only show public realm posts or posts without realm
|
||||||
|
// Get public realm ids in the caller and pass them
|
||||||
|
query = query.Where(p => p.RealmId == null); // Modify in caller
|
||||||
|
}
|
||||||
else
|
else
|
||||||
query = query.Where(p =>
|
query = query.Where(p =>
|
||||||
p.Realm == null || p.Realm.IsPublic || p.RealmId == null || userRealms.Contains(p.RealmId.Value));
|
p.RealmId == null || userRealms.Contains(p.RealmId.Value));
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
@@ -339,6 +341,23 @@ public class ActivityService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
|
||||||
|
{
|
||||||
|
var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId.Value).Distinct().ToList();
|
||||||
|
if (!postRealmIds.Any()) return;
|
||||||
|
|
||||||
|
var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
|
||||||
|
var realmDict = realms.ToDictionary(r => r.Id, r => r);
|
||||||
|
|
||||||
|
foreach (var post in posts.Where(p => p.RealmId != null))
|
||||||
|
{
|
||||||
|
if (post.RealmId != null && realmDict.TryGetValue(post.RealmId.Value, out var realm))
|
||||||
|
{
|
||||||
|
post.Realm = realm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static double CalculatePopularity(List<SnPost> posts)
|
private static double CalculatePopularity(List<SnPost> posts)
|
||||||
{
|
{
|
||||||
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
|
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Sphere.WebReader;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.EntityFrameworkCore.Query;
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
@@ -33,26 +34,23 @@ public class AppDatabase(
|
|||||||
public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
|
public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
|
||||||
public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
|
public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Shared.Models.SnPoll> Polls { get; set; } = null!;
|
public DbSet<SnPoll> Polls { get; set; } = null!;
|
||||||
public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!;
|
public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!;
|
||||||
public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!;
|
public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Shared.Models.SnRealm> Realms { get; set; } = null!;
|
|
||||||
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
|
|
||||||
|
|
||||||
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
|
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
|
||||||
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
|
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
|
||||||
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
|
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
|
||||||
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
|
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
|
||||||
public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!;
|
public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Shared.Models.SnSticker> Stickers { get; set; } = null!;
|
public DbSet<SnSticker> Stickers { get; set; } = null!;
|
||||||
public DbSet<StickerPack> StickerPacks { get; set; } = null!;
|
public DbSet<StickerPack> StickerPacks { get; set; } = null!;
|
||||||
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
|
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!;
|
public DbSet<WebArticle> WebArticles { get; set; } = null!;
|
||||||
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!;
|
public DbSet<WebFeed> WebFeeds { get; set; } = null!;
|
||||||
public DbSet<WebReader.WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!;
|
public DbSet<WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -112,14 +110,6 @@ public class AppDatabase(
|
|||||||
.WithMany(c => c.Posts)
|
.WithMany(c => c.Posts)
|
||||||
.UsingEntity(j => j.ToTable("post_collection_links"));
|
.UsingEntity(j => j.ToTable("post_collection_links"));
|
||||||
|
|
||||||
modelBuilder.Entity<SnRealmMember>()
|
|
||||||
.HasKey(pm => new { pm.RealmId, pm.AccountId });
|
|
||||||
modelBuilder.Entity<SnRealmMember>()
|
|
||||||
.HasOne(pm => pm.Realm)
|
|
||||||
.WithMany(p => p.Members)
|
|
||||||
.HasForeignKey(pm => pm.RealmId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<SnChatMember>()
|
modelBuilder.Entity<SnChatMember>()
|
||||||
.HasKey(pm => new { pm.Id });
|
.HasKey(pm => new { pm.Id });
|
||||||
modelBuilder.Entity<SnChatMember>()
|
modelBuilder.Entity<SnChatMember>()
|
||||||
@@ -150,10 +140,10 @@ public class AppDatabase(
|
|||||||
.HasForeignKey(m => m.SenderId)
|
.HasForeignKey(m => m.SenderId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<WebReader.WebFeed>()
|
modelBuilder.Entity<WebFeed>()
|
||||||
.HasIndex(f => f.Url)
|
.HasIndex(f => f.Url)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
modelBuilder.Entity<WebReader.WebArticle>()
|
modelBuilder.Entity<WebArticle>()
|
||||||
.HasIndex(a => a.Url)
|
.HasIndex(a => a.Url)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
DysonNetwork.Sphere/Autocompletion/AutocompletionService.cs
Normal file
144
DysonNetwork.Sphere/Autocompletion/AutocompletionService.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Autocompletion;
|
||||||
|
|
||||||
|
public class AutocompletionService(AppDatabase db, RemoteAccountService remoteAccountsHelper, RemoteRealmService remoteRealmService)
|
||||||
|
{
|
||||||
|
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 remoteAccountsHelper.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)
|
||||||
|
{
|
||||||
|
// TODO: Filter to realm members only - needs efficient implementation
|
||||||
|
// 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 remoteRealmService.SearchRealms(query, limit);
|
||||||
|
var autocompletions = realms.Select(r => new DysonNetwork.Shared.Models.Autocompletion
|
||||||
|
{
|
||||||
|
Type = "realm",
|
||||||
|
Keyword = "@r/" + r.Slug,
|
||||||
|
Data = r
|
||||||
|
});
|
||||||
|
results.AddRange(autocompletions);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Sphere.Autocompletion;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -17,7 +18,8 @@ public partial class ChatController(
|
|||||||
ChatService cs,
|
ChatService cs,
|
||||||
ChatRoomService crs,
|
ChatRoomService crs,
|
||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
AccountService.AccountServiceClient accounts
|
AccountService.AccountServiceClient accounts,
|
||||||
|
AutocompletionService aus
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
public class MarkMessageReadRequest
|
public class MarkMessageReadRequest
|
||||||
@@ -85,7 +87,8 @@ public partial class ChatController(
|
|||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
var member = await db.ChatMembers
|
var member = await db.ChatMembers
|
||||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||||
|
m.LeaveAt == null)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (member == null || member.Role < ChatMemberRole.Member)
|
if (member == null || member.Role < ChatMemberRole.Member)
|
||||||
return StatusCode(403, "You are not a member of this chat room.");
|
return StatusCode(403, "You are not a member of this chat room.");
|
||||||
@@ -127,7 +130,8 @@ public partial class ChatController(
|
|||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
var member = await db.ChatMembers
|
var member = await db.ChatMembers
|
||||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||||
|
m.LeaveAt == null)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (member == null || member.Role < ChatMemberRole.Member)
|
if (member == null || member.Role < ChatMemberRole.Member)
|
||||||
return StatusCode(403, "You are not a member of this chat room.");
|
return StatusCode(403, "You are not a member of this chat room.");
|
||||||
@@ -146,9 +150,74 @@ public partial class ChatController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
|
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
|
||||||
private static partial Regex MentionRegex();
|
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")]
|
[HttpPost("{roomId:guid}/messages")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[RequiredPermission("global", "chat.messages.create")]
|
[RequiredPermission("global", "chat.messages.create")]
|
||||||
@@ -186,6 +255,7 @@ public partial class ChatController(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate reply and forward message IDs exist
|
||||||
if (request.RepliedMessageId.HasValue)
|
if (request.RepliedMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var repliedMessage = await db.ChatMessages
|
var repliedMessage = await db.ChatMessages
|
||||||
@@ -206,28 +276,9 @@ public partial class ChatController(
|
|||||||
message.ForwardedMessageId = forwardedMessage.Id;
|
message.ForwardedMessageId = forwardedMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Content is not null)
|
// Extract mentioned users
|
||||||
{
|
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||||
var mentioned = MentionRegex()
|
request.ForwardedMessageId, roomId);
|
||||||
.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 => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
|
|
||||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
|
||||||
.Select(m => m.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
message.MembersMentioned = mentionedMembers;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
|
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
|
||||||
|
|
||||||
@@ -257,6 +308,7 @@ public partial class ChatController(
|
|||||||
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
|
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
|
||||||
return BadRequest("You cannot send an empty message.");
|
return BadRequest("You cannot send an empty message.");
|
||||||
|
|
||||||
|
// Validate reply and forward message IDs exist
|
||||||
if (request.RepliedMessageId.HasValue)
|
if (request.RepliedMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var repliedMessage = await db.ChatMessages
|
var repliedMessage = await db.ChatMessages
|
||||||
@@ -273,6 +325,11 @@ public partial class ChatController(
|
|||||||
return BadRequest("The message you're forwarding does not exist.");
|
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
|
// Call service method to update the message
|
||||||
await cs.UpdateMessageAsync(
|
await cs.UpdateMessageAsync(
|
||||||
message,
|
message,
|
||||||
@@ -322,11 +379,30 @@ public partial class ChatController(
|
|||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
var isMember = await db.ChatMembers
|
var isMember = await db.ChatMembers
|
||||||
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
.AnyAsync(m =>
|
||||||
|
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||||
if (!isMember)
|
if (!isMember)
|
||||||
return StatusCode(403, "You are not a member of this chat room.");
|
return StatusCode(403, "You are not a member of this chat room.");
|
||||||
|
|
||||||
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
|
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
|
||||||
return Ok(response);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ using DysonNetwork.Shared.Auth;
|
|||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using DysonNetwork.Sphere.Localization;
|
using DysonNetwork.Sphere.Localization;
|
||||||
using DysonNetwork.Sphere.Realm;
|
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
@@ -20,14 +20,14 @@ namespace DysonNetwork.Sphere.Chat;
|
|||||||
public class ChatRoomController(
|
public class ChatRoomController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
ChatRoomService crs,
|
ChatRoomService crs,
|
||||||
RealmService rs,
|
RemoteRealmService rs,
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
AccountService.AccountServiceClient accounts,
|
AccountService.AccountServiceClient accounts,
|
||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
ActionLogService.ActionLogServiceClient als,
|
ActionLogService.ActionLogServiceClient als,
|
||||||
RingService.RingServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
AccountClientHelper accountsHelper
|
RemoteAccountService remoteAccountsHelper
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
@@ -35,9 +35,12 @@ public class ChatRoomController(
|
|||||||
{
|
{
|
||||||
var chatRoom = await db.ChatRooms
|
var chatRoom = await db.ChatRooms
|
||||||
.Where(c => c.Id == id)
|
.Where(c => c.Id == id)
|
||||||
.Include(e => e.Realm)
|
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (chatRoom is null) return NotFound();
|
if (chatRoom is null) return NotFound();
|
||||||
|
|
||||||
|
if (chatRoom.RealmId != null)
|
||||||
|
chatRoom.Realm = await rs.GetRealm(chatRoom.RealmId.Value.ToString());
|
||||||
|
|
||||||
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
|
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
|
||||||
|
|
||||||
if (HttpContext.Items["CurrentUser"] is Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is Account currentUser)
|
||||||
@@ -203,7 +206,7 @@ public class ChatRoomController(
|
|||||||
if (request.RealmId is not null)
|
if (request.RealmId is not null)
|
||||||
{
|
{
|
||||||
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
|
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
|
||||||
RealmMemberRole.Moderator))
|
[RealmMemberRole.Moderator]))
|
||||||
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
|
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
|
||||||
chatRoom.RealmId = request.RealmId;
|
chatRoom.RealmId = request.RealmId;
|
||||||
}
|
}
|
||||||
@@ -301,7 +304,7 @@ public class ChatRoomController(
|
|||||||
if (chatRoom.RealmId is not null)
|
if (chatRoom.RealmId is not null)
|
||||||
{
|
{
|
||||||
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
||||||
RealmMemberRole.Moderator))
|
[RealmMemberRole.Moderator]))
|
||||||
return StatusCode(403, "You need at least be a realm moderator to update the chat.");
|
return StatusCode(403, "You need at least be a realm moderator to update the chat.");
|
||||||
}
|
}
|
||||||
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
|
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
|
||||||
@@ -309,13 +312,9 @@ public class ChatRoomController(
|
|||||||
|
|
||||||
if (request.RealmId is not null)
|
if (request.RealmId is not null)
|
||||||
{
|
{
|
||||||
var member = await db.RealmMembers
|
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
|
||||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
|
|
||||||
.Where(m => m.RealmId == request.RealmId)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (member is null || member.Role < RealmMemberRole.Moderator)
|
|
||||||
return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
|
return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
|
||||||
chatRoom.RealmId = member.RealmId;
|
chatRoom.RealmId = request.RealmId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.PictureId is not null)
|
if (request.PictureId is not null)
|
||||||
@@ -415,7 +414,7 @@ public class ChatRoomController(
|
|||||||
if (chatRoom.RealmId is not null)
|
if (chatRoom.RealmId is not null)
|
||||||
{
|
{
|
||||||
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
||||||
RealmMemberRole.Moderator))
|
[RealmMemberRole.Moderator]))
|
||||||
return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
|
return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
|
||||||
}
|
}
|
||||||
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner))
|
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner))
|
||||||
@@ -507,7 +506,7 @@ public class ChatRoomController(
|
|||||||
.Select(m => m.AccountId)
|
.Select(m => m.AccountId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var memberStatuses = await accountsHelper.GetAccountStatusBatch(members);
|
var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(members);
|
||||||
|
|
||||||
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
|
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
|
||||||
|
|
||||||
@@ -546,7 +545,7 @@ public class ChatRoomController(
|
|||||||
.OrderBy(m => m.JoinedAt)
|
.OrderBy(m => m.JoinedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var memberStatuses = await accountsHelper.GetAccountStatusBatch(
|
var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(
|
||||||
members.Select(m => m.AccountId).ToList()
|
members.Select(m => m.AccountId).ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -623,11 +622,7 @@ public class ChatRoomController(
|
|||||||
// Handle realm-owned chat rooms
|
// Handle realm-owned chat rooms
|
||||||
if (chatRoom.RealmId is not null)
|
if (chatRoom.RealmId is not null)
|
||||||
{
|
{
|
||||||
var realmMember = await db.RealmMembers
|
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
|
||||||
.Where(m => m.AccountId == accountId)
|
|
||||||
.Where(m => m.RealmId == chatRoom.RealmId)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
|
|
||||||
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
|
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -645,14 +640,37 @@ public class ChatRoomController(
|
|||||||
return StatusCode(403, "You cannot invite member with higher permission than yours.");
|
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.AccountId == request.RelatedUserId)
|
||||||
.Where(m => m.ChatRoomId == roomId)
|
.Where(m => m.ChatRoomId == roomId)
|
||||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
.FirstOrDefaultAsync();
|
||||||
.AnyAsync();
|
if (existingMember != null)
|
||||||
if (hasExistingMember)
|
{
|
||||||
|
if (existingMember.LeaveAt == null)
|
||||||
return BadRequest("This user has been joined the chat cannot be invited again.");
|
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
|
var newMember = new SnChatMember
|
||||||
{
|
{
|
||||||
AccountId = Guid.Parse(relatedUser.Id),
|
AccountId = Guid.Parse(relatedUser.Id),
|
||||||
@@ -809,11 +827,7 @@ public class ChatRoomController(
|
|||||||
// Check if the chat room is owned by a realm
|
// Check if the chat room is owned by a realm
|
||||||
if (chatRoom.RealmId is not null)
|
if (chatRoom.RealmId is not null)
|
||||||
{
|
{
|
||||||
var realmMember = await db.RealmMembers
|
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
|
||||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
|
|
||||||
.Where(m => m.RealmId == chatRoom.RealmId)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
|
|
||||||
return StatusCode(403, "You need at least be a realm moderator to change member roles.");
|
return StatusCode(403, "You need at least be a realm moderator to change member roles.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -876,12 +890,12 @@ public class ChatRoomController(
|
|||||||
if (chatRoom.RealmId is not null)
|
if (chatRoom.RealmId is not null)
|
||||||
{
|
{
|
||||||
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
|
||||||
RealmMemberRole.Moderator))
|
[RealmMemberRole.Moderator]))
|
||||||
return StatusCode(403, "You need at least be a realm moderator to remove members.");
|
return StatusCode(403, "You need at least be a realm moderator to remove members.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
|
if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), [ChatMemberRole.Moderator]))
|
||||||
return StatusCode(403, "You need at least be a moderator to remove members.");
|
return StatusCode(403, "You need at least be a moderator to remove members.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,15 +948,15 @@ public class ChatRoomController(
|
|||||||
if (existingMember != null)
|
if (existingMember != null)
|
||||||
{
|
{
|
||||||
if (existingMember.LeaveAt == null)
|
if (existingMember.LeaveAt == null)
|
||||||
{
|
return BadRequest("You are already a member of this chat room.");
|
||||||
|
|
||||||
existingMember.LeaveAt = null;
|
existingMember.LeaveAt = null;
|
||||||
db.Update(existingMember);
|
db.Update(existingMember);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_ = crs.PurgeRoomMembersCache(roomId);
|
_ = crs.PurgeRoomMembersCache(roomId);
|
||||||
|
|
||||||
return Ok(existingMember);
|
return Ok(existingMember);
|
||||||
}
|
}
|
||||||
return BadRequest("You are already a member of this chat room.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var newMember = new SnChatMember
|
var newMember = new SnChatMember
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.Chat;
|
|||||||
public class ChatRoomService(
|
public class ChatRoomService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
AccountClientHelper accountsHelper
|
RemoteAccountService remoteAccountsHelper
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private const string ChatRoomGroupPrefix = "chatroom:";
|
private const string ChatRoomGroupPrefix = "chatroom:";
|
||||||
@@ -45,7 +45,8 @@ public class ChatRoomService(
|
|||||||
if (member is not null) return member;
|
if (member is not null) return member;
|
||||||
|
|
||||||
member = await db.ChatMembers
|
member = await db.ChatMembers
|
||||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null && m.LeaveAt == null)
|
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null &&
|
||||||
|
m.LeaveAt == null)
|
||||||
.Include(m => m.ChatRoom)
|
.Include(m => m.ChatRoom)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ public class ChatRoomService(
|
|||||||
? await db.ChatMembers
|
? await db.ChatMembers
|
||||||
.Where(m => directRoomsId.Contains(m.ChatRoomId))
|
.Where(m => directRoomsId.Contains(m.ChatRoomId))
|
||||||
.Where(m => m.AccountId != userId)
|
.Where(m => m.AccountId != userId)
|
||||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
// Ignored the joined at condition here to keep showing userinfo when other didn't accept the invite of DM
|
||||||
.ToListAsync()
|
.ToListAsync()
|
||||||
: [];
|
: [];
|
||||||
members = await LoadMemberAccounts(members);
|
members = await LoadMemberAccounts(members);
|
||||||
@@ -146,7 +147,7 @@ public class ChatRoomService(
|
|||||||
|
|
||||||
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)
|
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)
|
||||||
{
|
{
|
||||||
var account = await accountsHelper.GetAccount(member.AccountId);
|
var account = await remoteAccountsHelper.GetAccount(member.AccountId);
|
||||||
member.Account = SnAccount.FromProtoValue(account);
|
member.Account = SnAccount.FromProtoValue(account);
|
||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
@@ -154,14 +155,17 @@ public class ChatRoomService(
|
|||||||
public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members)
|
public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members)
|
||||||
{
|
{
|
||||||
var accountIds = members.Select(m => m.AccountId).ToList();
|
var accountIds = members.Select(m => m.AccountId).ToList();
|
||||||
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
|
var accounts = (await remoteAccountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
|
||||||
|
|
||||||
return [.. members.Select(m =>
|
return
|
||||||
|
[
|
||||||
|
.. members.Select(m =>
|
||||||
{
|
{
|
||||||
if (accounts.TryGetValue(m.AccountId, out var account))
|
if (accounts.TryGetValue(m.AccountId, out var account))
|
||||||
m.Account = SnAccount.FromProtoValue(account);
|
m.Account = SnAccount.FromProtoValue(account);
|
||||||
return m;
|
return m;
|
||||||
})];
|
})
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";
|
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";
|
||||||
|
|||||||
@@ -198,8 +198,6 @@ public partial class ChatService(
|
|||||||
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
|
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
||||||
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
|
||||||
message.UpdatedAt = message.CreatedAt;
|
|
||||||
|
|
||||||
// First complete the save operation
|
// First complete the save operation
|
||||||
db.ChatMessages.Add(message);
|
db.ChatMessages.Add(message);
|
||||||
@@ -209,20 +207,25 @@ public partial class ChatService(
|
|||||||
await CreateFileReferencesForMessageAsync(message);
|
await CreateFileReferencesForMessageAsync(message);
|
||||||
|
|
||||||
// Then start the delivery process
|
// Then start the delivery process
|
||||||
|
var localMessage = message;
|
||||||
|
var localSender = sender;
|
||||||
|
var localRoom = room;
|
||||||
|
var localLogger = logger;
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DeliverMessageAsync(message, sender, room);
|
await DeliverMessageAsync(localMessage, localSender, localRoom);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// 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.Sender = sender;
|
||||||
message.ChatRoom = room;
|
message.ChatRoom = room;
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Registry;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Realm;
|
namespace DysonNetwork.Sphere.Chat;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/realms/{slug}")]
|
[Route("/api/realms/{slug}")]
|
||||||
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase
|
public class RealmChatController(AppDatabase db, RemoteRealmService rs) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("chat")]
|
[HttpGet("chat")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug)
|
public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug)
|
||||||
{
|
{
|
||||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
var currentUser = HttpContext.Items["CurrentUser"] as Shared.Proto.Account;
|
||||||
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
|
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
var realm = await db.Realms
|
var realm = await rs.GetRealmBySlug(slug);
|
||||||
.Where(r => r.Slug == slug)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (realm is null) return NotFound();
|
|
||||||
if (!realm.IsPublic)
|
if (!realm.IsPublic)
|
||||||
{
|
{
|
||||||
if (currentUser is null) return Unauthorized();
|
if (currentUser is null) return Unauthorized();
|
||||||
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
|
if (!await rs.IsMemberWithRole(realm.Id, accountId, [RealmMemberRole.Normal]))
|
||||||
return StatusCode(403, "You need at least one member to view the realm's chat.");
|
return StatusCode(403, "You need at least one member to view the realm's chat.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Discovery;
|
namespace DysonNetwork.Sphere.Discovery;
|
||||||
|
|
||||||
public class DiscoveryService(AppDatabase appDatabase)
|
public class DiscoveryService(RemoteRealmService remoteRealmService)
|
||||||
{
|
{
|
||||||
public Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync(
|
public async Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync(
|
||||||
string? query,
|
string? query,
|
||||||
int take = 10,
|
int take = 10,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
bool randomizer = false
|
bool randomizer = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var realmsQuery = appDatabase.Realms
|
var allRealms = await remoteRealmService.GetPublicRealms();
|
||||||
.Where(r => r.IsCommunity)
|
var communityRealms = allRealms.Where(r => r.IsCommunity);
|
||||||
.OrderByDescending(r => r.CreatedAt)
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(query))
|
if (!string.IsNullOrEmpty(query))
|
||||||
realmsQuery = realmsQuery.Where(r =>
|
{
|
||||||
EF.Functions.ILike(r.Name, $"%{query}%") ||
|
communityRealms = communityRealms.Where(r =>
|
||||||
EF.Functions.ILike(r.Description, $"%{query}%")
|
r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
);
|
);
|
||||||
realmsQuery = randomizer
|
}
|
||||||
? realmsQuery.OrderBy(r => EF.Functions.Random())
|
|
||||||
: realmsQuery.OrderByDescending(r => r.CreatedAt);
|
|
||||||
|
|
||||||
return realmsQuery.Skip(offset).Take(take).ToListAsync();
|
// Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
|
||||||
|
var orderedRealms = randomizer
|
||||||
|
? communityRealms.OrderBy(_ => Random.Shared.Next())
|
||||||
|
: communityRealms;
|
||||||
|
|
||||||
|
return orderedRealms.Skip(offset).Take(take).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
|
||||||
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
|
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
|
||||||
<PackageReference Include="Markdig" Version="0.41.3"/>
|
<PackageReference Include="Markdig" Version="0.41.3"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" 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.AspNetCore" Version="1.13.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
|
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
|
||||||
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
|
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.6" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
|
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
|
||||||
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
|
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -122,7 +122,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
2130
DysonNetwork.Sphere/Migrations/20251021154500_ChangeRealmReferenceMode.Designer.cs
generated
Normal file
2130
DysonNetwork.Sphere/Migrations/20251021154500_ChangeRealmReferenceMode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ChangeRealmReferenceMode : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_chat_rooms_realms_realm_id",
|
||||||
|
table: "chat_rooms");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_posts_realms_realm_id",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_posts_realm_id",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_chat_rooms_realm_id",
|
||||||
|
table: "chat_rooms");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "sn_realm_id",
|
||||||
|
table: "chat_rooms",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_chat_rooms_sn_realm_id",
|
||||||
|
table: "chat_rooms",
|
||||||
|
column: "sn_realm_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_chat_rooms_realms_sn_realm_id",
|
||||||
|
table: "chat_rooms",
|
||||||
|
column: "sn_realm_id",
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_chat_rooms_realms_sn_realm_id",
|
||||||
|
table: "chat_rooms");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_chat_rooms_sn_realm_id",
|
||||||
|
table: "chat_rooms");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sn_realm_id",
|
||||||
|
table: "chat_rooms");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_posts_realm_id",
|
||||||
|
table: "posts",
|
||||||
|
column: "realm_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_chat_rooms_realm_id",
|
||||||
|
table: "chat_rooms",
|
||||||
|
column: "realm_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_chat_rooms_realms_realm_id",
|
||||||
|
table: "chat_rooms",
|
||||||
|
column: "realm_id",
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_posts_realms_realm_id",
|
||||||
|
table: "posts",
|
||||||
|
column: "realm_id",
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user