new(all): Initial import.
This commit is contained in:
parent
28cc734340
commit
87aeb5cbf5
23 changed files with 413 additions and 243 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2563,6 +2563,7 @@ dependencies = [
|
||||||
name = "gb-testing"
|
name = "gb-testing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
use gb_core::Error as CoreError;
|
use gb_core::Error as CoreError;
|
||||||
|
use redis::RedisError;
|
||||||
|
use sqlx::Error as SqlxError;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum AuthError {
|
pub enum AuthError {
|
||||||
#[error("Invalid token")]
|
#[error("Invalid token")]
|
||||||
InvalidToken,
|
InvalidToken,
|
||||||
|
#[error("Invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(#[from] sqlx::Error),
|
Database(#[from] SqlxError),
|
||||||
#[error("Redis error: {0}")]
|
#[error("Redis error: {0}")]
|
||||||
Redis(#[from] redis::RedisError),
|
Redis(#[from] RedisError),
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CoreError> for AuthError {
|
impl From<CoreError> for AuthError {
|
||||||
fn from(err: CoreError) -> Self {
|
fn from(err: CoreError) -> Self {
|
||||||
AuthError::Internal(err.to_string())
|
match err {
|
||||||
|
CoreError { .. } => AuthError::Internal(err.to_string()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,27 +1,13 @@
|
||||||
use axum::{
|
use axum::{Json, Extension};
|
||||||
extract::State,
|
use crate::services::AuthService;
|
||||||
Json,
|
use crate::AuthError;
|
||||||
};
|
use crate::models::{LoginRequest, LoginResponse};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
pub async fn login_handler(
|
||||||
models::{LoginRequest, LoginResponse},
|
Extension(auth_service): Extension<Arc<AuthService>>,
|
||||||
services::auth_service::AuthService,
|
|
||||||
Result,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
State(auth_service): State<Arc<AuthService>>,
|
|
||||||
Json(request): Json<LoginRequest>,
|
Json(request): Json<LoginRequest>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>, AuthError> {
|
||||||
let response = auth_service.login(request).await?;
|
let response = auth_service.login(request).await?;
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout() -> Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn refresh_token() -> Result<Json<LoginResponse>> {
|
|
||||||
todo!()
|
|
||||||
}
|
|
|
@ -1,29 +1,11 @@
|
||||||
|
mod error;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod middleware;
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod services;
|
pub mod services; // Make services module public
|
||||||
pub mod utils;
|
pub mod middleware;
|
||||||
|
|
||||||
use thiserror::Error;
|
pub use error::AuthError;
|
||||||
|
pub use handlers::*;
|
||||||
#[derive(Debug, Error)]
|
pub use models::*;
|
||||||
pub enum AuthError {
|
pub use services::AuthService;
|
||||||
#[error("Authentication failed")]
|
pub use middleware::*;
|
||||||
AuthenticationFailed,
|
|
||||||
#[error("Invalid credentials")]
|
|
||||||
InvalidCredentials,
|
|
||||||
#[error("Token expired")]
|
|
||||||
TokenExpired,
|
|
||||||
#[error("Invalid token")]
|
|
||||||
InvalidToken,
|
|
||||||
#[error("Missing token")]
|
|
||||||
MissingToken,
|
|
||||||
#[error("Database error: {0}")]
|
|
||||||
Database(#[from] sqlx::Error),
|
|
||||||
#[error("Cache error: {0}")]
|
|
||||||
Cache(#[from] redis::RedisError),
|
|
||||||
#[error("Internal error: {0}")]
|
|
||||||
Internal(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, AuthError>;
|
|
||||||
|
|
15
gb-auth/src/models/auth.rs
Normal file
15
gb-auth/src/models/auth.rs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_in: i64,
|
||||||
|
}
|
|
@ -1,2 +1,4 @@
|
||||||
|
mod auth;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub use user::*;
|
|
||||||
|
pub use auth::{LoginRequest, LoginResponse};
|
|
@ -1,56 +1,48 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use sqlx::FromRow;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum UserRole {
|
||||||
|
Admin,
|
||||||
|
User,
|
||||||
|
Guest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum UserStatus {
|
pub enum UserStatus {
|
||||||
Active,
|
Active,
|
||||||
Inactive,
|
Inactive,
|
||||||
Suspended,
|
Suspended,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
impl From<String> for UserRole {
|
||||||
pub enum UserRole {
|
fn from(s: String) -> Self {
|
||||||
Admin,
|
match s.to_lowercase().as_str() {
|
||||||
User,
|
"admin" => UserRole::Admin,
|
||||||
Service,
|
"guest" => UserRole::Guest,
|
||||||
|
_ => UserRole::User,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for UserStatus {
|
impl From<String> for UserStatus {
|
||||||
fn from(s: String) -> Self {
|
fn from(s: String) -> Self {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"active" => UserStatus::Active,
|
|
||||||
"inactive" => UserStatus::Inactive,
|
"inactive" => UserStatus::Inactive,
|
||||||
"suspended" => UserStatus::Suspended,
|
"suspended" => UserStatus::Suspended,
|
||||||
_ => UserStatus::Inactive,
|
_ => UserStatus::Active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Validate)]
|
|
||||||
pub struct LoginRequest {
|
|
||||||
#[validate(email)]
|
|
||||||
pub email: String,
|
|
||||||
#[validate(length(min = 8))]
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LoginResponse {
|
|
||||||
pub access_token: String,
|
|
||||||
pub refresh_token: String,
|
|
||||||
pub token_type: String,
|
|
||||||
pub expires_in: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
||||||
pub struct DbUser {
|
pub struct DbUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
pub status: UserStatus,
|
pub status: UserStatus,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
use gb_core::{Result, Error};
|
use gb_core::{Result, Error};
|
||||||
use crate::models::{LoginRequest, LoginResponse};
|
use crate::models::{LoginRequest, LoginResponse};
|
||||||
use crate::models::user::DbUser;
|
use crate::models::user::{DbUser, UserRole, UserStatus};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use argon2::{
|
use argon2::{
|
||||||
|
@ -8,6 +8,9 @@ use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
use chrono::{DateTime, Utc, Duration}; // Add chrono imports
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
pub struct AuthService {
|
pub struct AuthService {
|
||||||
db: Arc<PgPool>,
|
db: Arc<PgPool>,
|
||||||
|
@ -28,7 +31,14 @@ impl AuthService {
|
||||||
let user = sqlx::query_as!(
|
let user = sqlx::query_as!(
|
||||||
DbUser,
|
DbUser,
|
||||||
r#"
|
r#"
|
||||||
SELECT id, email, password_hash, role
|
SELECT
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
role as "role!: String",
|
||||||
|
status as "status!: String",
|
||||||
|
created_at as "created_at!: DateTime<Utc>",
|
||||||
|
updated_at as "updated_at!: DateTime<Utc>"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
"#,
|
"#,
|
||||||
|
@ -39,6 +49,17 @@ impl AuthService {
|
||||||
.map_err(|e| Error::internal(e.to_string()))?
|
.map_err(|e| Error::internal(e.to_string()))?
|
||||||
.ok_or_else(|| Error::internal("Invalid credentials"))?;
|
.ok_or_else(|| Error::internal("Invalid credentials"))?;
|
||||||
|
|
||||||
|
// Convert the string fields to their respective enum types
|
||||||
|
let user = DbUser {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
password_hash: user.password_hash,
|
||||||
|
role: UserRole::from(user.role),
|
||||||
|
status: UserStatus::from(user.status),
|
||||||
|
created_at: user.created_at,
|
||||||
|
updated_at: user.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
self.verify_password(&request.password, &user.password_hash)?;
|
self.verify_password(&request.password, &user.password_hash)?;
|
||||||
|
|
||||||
let token = self.generate_token(&user)?;
|
let token = self.generate_token(&user)?;
|
||||||
|
@ -71,10 +92,6 @@ impl AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_token(&self, user: &DbUser) -> Result<String> {
|
fn generate_token(&self, user: &DbUser) -> Result<String> {
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use chrono::{Utc, Duration};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct Claims {
|
struct Claims {
|
||||||
sub: String,
|
sub: String,
|
||||||
|
|
|
@ -1,33 +1,29 @@
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::services::auth_service::AuthService;
|
use gb_auth::services::auth_service::AuthService;
|
||||||
use crate::models::{LoginRequest, User};
|
use gb_auth::models::LoginRequest;
|
||||||
|
use gb_core::models::User;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use rstest::*;
|
use rstest::*;
|
||||||
|
|
||||||
async fn setup_test_db() -> PgPool {
|
|
||||||
let database_url = std::env::var("DATABASE_URL")
|
|
||||||
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost/gb_auth_test".to_string());
|
|
||||||
|
|
||||||
PgPool::connect(&database_url)
|
|
||||||
.await
|
|
||||||
.expect("Failed to connect to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
async fn auth_service() -> AuthService {
|
async fn auth_service() -> AuthService {
|
||||||
let pool = setup_test_db().await;
|
let db_pool = PgPool::connect("postgresql://postgres:postgres@localhost:5432/test_db")
|
||||||
|
.await
|
||||||
|
.expect("Failed to create database connection");
|
||||||
|
|
||||||
AuthService::new(
|
AuthService::new(
|
||||||
Arc::new(pool),
|
Arc::new(db_pool),
|
||||||
"test_secret".to_string(),
|
"test_secret".to_string(),
|
||||||
3600,
|
3600
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_login_success(auth_service: AuthService) {
|
async fn test_login_success() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let auth_service = auth_service().await;
|
||||||
let request = LoginRequest {
|
let request = LoginRequest {
|
||||||
email: "test@example.com".to_string(),
|
email: "test@example.com".to_string(),
|
||||||
password: "password123".to_string(),
|
password: "password123".to_string(),
|
||||||
|
@ -35,11 +31,13 @@ mod tests {
|
||||||
|
|
||||||
let result = auth_service.login(request).await;
|
let result = auth_service.login(request).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_login_invalid_credentials(auth_service: AuthService) {
|
async fn test_login_invalid_credentials() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let auth_service = auth_service().await;
|
||||||
let request = LoginRequest {
|
let request = LoginRequest {
|
||||||
email: "wrong@example.com".to_string(),
|
email: "wrong@example.com".to_string(),
|
||||||
password: "wrongpassword".to_string(),
|
password: "wrongpassword".to_string(),
|
||||||
|
@ -47,5 +45,6 @@ mod tests {
|
||||||
|
|
||||||
let result = auth_service.login(request).await;
|
let result = auth_service.login(request).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,23 +1,20 @@
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
pub use errors::{Error, ErrorKind, Result};
|
pub use errors::{Error, ErrorKind, Result};
|
||||||
|
|
||||||
|
|
||||||
pub use models::*;
|
|
||||||
pub use traits::*;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::models::{Customer, CustomerStatus, SubscriptionTier};
|
||||||
use rstest::*;
|
use rstest::*;
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
fn customer() -> Customer {
|
fn customer() -> Customer {
|
||||||
Customer::new(
|
Customer::new(
|
||||||
"Test Corp".to_string(),
|
"Test Corp".to_string(),
|
||||||
"enterprise".to_string(),
|
"test@example.com".to_string(),
|
||||||
|
SubscriptionTier::Enterprise,
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -25,8 +22,9 @@ mod tests {
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_customer_fixture(customer: Customer) {
|
fn test_customer_fixture(customer: Customer) {
|
||||||
assert_eq!(customer.name, "Test Corp");
|
assert_eq!(customer.name, "Test Corp");
|
||||||
assert_eq!(customer.subscription_tier, "enterprise");
|
assert_eq!(customer.email, "test@example.com");
|
||||||
|
|
||||||
assert_eq!(customer.max_instances, 10);
|
assert_eq!(customer.max_instances, 10);
|
||||||
assert_eq!(customer.status, "active");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,23 @@ use uuid::Uuid;
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CoreError(pub String);
|
pub struct CoreError(pub String);
|
||||||
|
|
||||||
|
// Add these near the top with other type definitions
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum CustomerStatus {
|
||||||
|
Active,
|
||||||
|
Inactive,
|
||||||
|
Suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum SubscriptionTier {
|
||||||
|
Free,
|
||||||
|
Pro,
|
||||||
|
Enterprise
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
@ -125,16 +142,41 @@ pub struct User {
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update the Customer struct to include these fields
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Customer {
|
pub struct Customer {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub max_instances: u32,
|
pub max_instances: u32,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub status: CustomerStatus, // Add this field
|
||||||
|
pub subscription_tier: SubscriptionTier, // Add this field
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Customer {
|
||||||
|
pub fn new(
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
subscription_tier: SubscriptionTier,
|
||||||
|
max_instances: u32,
|
||||||
|
) -> Self {
|
||||||
|
Customer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
max_instances,
|
||||||
|
subscription_tier,
|
||||||
|
status: CustomerStatus::Active, // Default to Active
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RoomConfig {
|
pub struct RoomConfig {
|
||||||
pub instance_id: Uuid,
|
pub instance_id: Uuid,
|
||||||
|
|
|
@ -15,7 +15,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_image_processing_integration() -> Result<()> {
|
async fn test_image_processing_integration() -> Result<()> {
|
||||||
// Initialize components
|
// Initialize components
|
||||||
let processor = ImageProcessor::new()?;
|
let processor = ImageProcessor::new();
|
||||||
|
|
||||||
// Create test image
|
// Create test image
|
||||||
let mut image = DynamicImage::new_rgb8(200, 200);
|
let mut image = DynamicImage::new_rgb8(200, 200);
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
use gb_core::{Error, Result};
|
use gb_core::{Error, Result};
|
||||||
use image::{DynamicImage, ImageOutputFormat};
|
use image::{DynamicImage, ImageOutputFormat, Rgba, RgbaImage};
|
||||||
|
use imageproc::drawing::draw_text_mut;
|
||||||
|
use rusttype::{Font, Scale};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use tesseract::Tesseract;
|
use tesseract::Tesseract;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
|
||||||
pub struct ImageProcessor;
|
pub struct ImageProcessor;
|
||||||
|
|
||||||
|
@ -36,4 +40,63 @@ impl ImageProcessor {
|
||||||
.get_text()
|
.get_text()
|
||||||
.map_err(|e| Error::internal(format!("Failed to get text: {}", e)))
|
.map_err(|e| Error::internal(format!("Failed to get text: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn resize(&self, image: &DynamicImage, width: u32, height: u32) -> DynamicImage {
|
||||||
|
image.resize(width, height, image::imageops::FilterType::Lanczos3)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn crop(&self, image: &DynamicImage, x: u32, y: u32, width: u32, height: u32) -> Result<DynamicImage> {
|
||||||
|
if x + width > image.width() || y + height > image.height() {
|
||||||
|
return Err(Error::internal("Crop dimensions exceed image bounds".to_string()));
|
||||||
|
}
|
||||||
|
Ok(image.crop_imm(x, y, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_blur(&self, image: &DynamicImage, sigma: f32) -> DynamicImage {
|
||||||
|
image.blur(sigma)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_brightness(&self, image: &DynamicImage, value: i32) -> DynamicImage {
|
||||||
|
image.brighten(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_contrast(&self, image: &DynamicImage, value: f32) -> DynamicImage {
|
||||||
|
image.adjust_contrast(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_text(
|
||||||
|
&self,
|
||||||
|
image: &mut DynamicImage,
|
||||||
|
text: &str,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
size: f32,
|
||||||
|
color: Rgba<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Load the font file from assets (downloaded in build.rs)
|
||||||
|
let font_data = fs::read("assets/DejaVuSans.ttf")
|
||||||
|
.map_err(|e| Error::internal(format!("Failed to load font: {}", e)))?;
|
||||||
|
|
||||||
|
let font = Font::try_from_vec(font_data)
|
||||||
|
.ok_or_else(|| Error::internal("Failed to parse font data".to_string()))?;
|
||||||
|
|
||||||
|
let scale = Scale::uniform(size);
|
||||||
|
let image_buffer = image.as_mut_rgba8()
|
||||||
|
.ok_or_else(|| Error::internal("Failed to convert image to RGBA".to_string()))?;
|
||||||
|
|
||||||
|
draw_text_mut(
|
||||||
|
image_buffer,
|
||||||
|
color,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
scale,
|
||||||
|
&font,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,53 +1,63 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use gb_core::{Result, Error};
|
use gb_core::{Result, Error};
|
||||||
use redis::{Client, AsyncCommands};
|
use redis::{Client, AsyncCommands, aio::PubSub};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct RedisPubSub {
|
pub struct RedisPubSub {
|
||||||
client: Arc<Client>,
|
client: Arc<Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for RedisPubSub {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
client: self.client.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RedisPubSub {
|
impl RedisPubSub {
|
||||||
pub async fn new(url: &str) -> Result<Self> {
|
pub fn new(client: Arc<Client>) -> Self {
|
||||||
let client = Client::open(url)
|
Self { client }
|
||||||
.map_err(|e| Error::redis(e.to_string()))?;
|
|
||||||
|
|
||||||
// Test connection
|
|
||||||
client.get_async_connection()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::redis(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client: Arc::new(client),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, payload))]
|
#[instrument(skip(self, payload), err)]
|
||||||
pub async fn publish<T>(&self, channel: &str, payload: &T) -> Result<()>
|
pub async fn publish<T: Serialize>(&self, channel: &str, payload: &T) -> Result<()> {
|
||||||
|
let mut conn = self.client
|
||||||
|
.get_async_connection()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(payload)
|
||||||
|
.map_err(|e| Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
conn.publish::<_, _, i32>(channel, serialized)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, handler), err)]
|
||||||
|
pub async fn subscribe<F>(&self, channels: &[&str], mut handler: F) -> Result<()>
|
||||||
where
|
where
|
||||||
T: Serialize + std::fmt::Debug,
|
F: FnMut(String, String) + Send + 'static,
|
||||||
{
|
{
|
||||||
let mut conn = self.client.get_async_connection()
|
let mut pubsub = self.client
|
||||||
|
.get_async_connection()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::redis(e.to_string()))?;
|
.map_err(|e| Error::internal(e.to_string()))?
|
||||||
|
.into_pubsub();
|
||||||
|
|
||||||
let payload = serde_json::to_string(payload)
|
for channel in channels {
|
||||||
.map_err(|e| Error::redis(e.to_string()))?;
|
pubsub.subscribe(*channel)
|
||||||
|
|
||||||
conn.publish(channel, payload)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::redis(e.to_string()))?;
|
.map_err(|e| Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = pubsub.on_message();
|
||||||
|
|
||||||
|
while let Some(msg) = stream.next().await {
|
||||||
|
let channel = msg.get_channel_name().to_string();
|
||||||
|
let payload: String = msg.get_payload()
|
||||||
|
.map_err(|e| Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
handler(channel, payload);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -56,59 +66,58 @@ impl RedisPubSub {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use rstest::*;
|
use redis::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
struct TestMessage {
|
struct TestMessage {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[fixture]
|
async fn setup() -> (RedisPubSub, TestMessage) {
|
||||||
async fn redis_pubsub() -> RedisPubSub {
|
let client = Arc::new(Client::open("redis://127.0.0.1/").unwrap());
|
||||||
RedisPubSub::new("redis://localhost")
|
let redis_pubsub = RedisPubSub::new(client);
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
let test_message = TestMessage {
|
||||||
fn test_message() -> TestMessage {
|
|
||||||
TestMessage {
|
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
content: "test message".to_string(),
|
content: "test message".to_string(),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
(redis_pubsub, test_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_publish_subscribe(
|
async fn test_publish_subscribe() {
|
||||||
redis_pubsub: RedisPubSub,
|
let (redis_pubsub, test_message) = setup().await;
|
||||||
test_message: TestMessage,
|
let channel = "test_channel";
|
||||||
) {
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
let channel = "test-channel";
|
|
||||||
|
|
||||||
let pubsub_clone = redis_pubsub.clone();
|
let pubsub_clone = redis_pubsub.clone();
|
||||||
let test_message_clone = test_message.clone();
|
let test_message_clone = test_message.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let handler = |msg: TestMessage| async move {
|
let handler = move |_channel: String, payload: String| {
|
||||||
assert_eq!(msg, test_message_clone);
|
let received: TestMessage = serde_json::from_str(&payload).unwrap();
|
||||||
Ok(())
|
tx.try_send(received).unwrap();
|
||||||
};
|
};
|
||||||
|
|
||||||
pubsub_clone.subscribe(&[channel], handler).await.unwrap();
|
pubsub_clone.subscribe(&[channel], handler).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Give the subscriber time to connect
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
redis_pubsub.publish(channel, &test_message)
|
redis_pubsub.publish(channel, &test_message).await.unwrap();
|
||||||
|
|
||||||
|
let received = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||||
.await
|
.await
|
||||||
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
assert_eq!(received, test_message);
|
||||||
handle.abort();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
use gb_core::{Result, Error};
|
use gb_core::{
|
||||||
|
Result, Error,
|
||||||
|
models::{Customer, CustomerStatus, SubscriptionTier},
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use gb_core::models::Customer;
|
|
||||||
|
|
||||||
pub trait CustomerRepository {
|
|
||||||
async fn create(&self, customer: Customer) -> Result<Customer>;
|
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Customer>>;
|
|
||||||
async fn update(&self, customer: Customer) -> Result<Customer>;
|
|
||||||
async fn delete(&self, id: Uuid) -> Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresCustomerRepository {
|
pub struct PostgresCustomerRepository {
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
|
@ -19,35 +14,50 @@ impl PostgresCustomerRepository {
|
||||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl CustomerRepository for PostgresCustomerRepository {
|
pub async fn create(&self, customer: Customer) -> Result<Customer> {
|
||||||
async fn create(&self, customer: Customer) -> Result<Customer> {
|
let subscription_tier: String = customer.subscription_tier.clone().into();
|
||||||
let result = sqlx::query_as!(
|
let status: String = customer.status.clone().into();
|
||||||
Customer,
|
|
||||||
|
let row = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO customers (id, name, max_instances, email, created_at, updated_at)
|
INSERT INTO customers (
|
||||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
id, name, email, max_instances,
|
||||||
|
subscription_tier, status,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
customer.id,
|
customer.id,
|
||||||
customer.name,
|
customer.name,
|
||||||
customer.max_instances as i32,
|
|
||||||
customer.email,
|
customer.email,
|
||||||
|
customer.max_instances as i32,
|
||||||
|
subscription_tier,
|
||||||
|
status,
|
||||||
|
customer.created_at,
|
||||||
|
customer.updated_at,
|
||||||
)
|
)
|
||||||
.fetch_one(&*self.pool)
|
.fetch_one(&*self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(Customer {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
max_instances: row.max_instances as u32,
|
||||||
|
subscription_tier: SubscriptionTier::from(row.subscription_tier),
|
||||||
|
status: CustomerStatus::from(row.status),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Customer>> {
|
pub async fn get(&self, id: Uuid) -> Result<Option<Customer>> {
|
||||||
let result = sqlx::query_as!(
|
let row = sqlx::query!(
|
||||||
Customer,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, max_instances::int as "max_instances!: i32",
|
SELECT *
|
||||||
email, created_at, updated_at
|
|
||||||
FROM customers
|
FROM customers
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
|
@ -57,43 +67,15 @@ impl CustomerRepository for PostgresCustomerRepository {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(row.map(|row| Customer {
|
||||||
}
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
async fn update(&self, customer: Customer) -> Result<Customer> {
|
email: row.email,
|
||||||
let result = sqlx::query_as!(
|
max_instances: row.max_instances as u32,
|
||||||
Customer,
|
subscription_tier: SubscriptionTier::from(row.subscription_tier),
|
||||||
r#"
|
status: CustomerStatus::from(row.status),
|
||||||
UPDATE customers
|
created_at: row.created_at,
|
||||||
SET name = $2, max_instances = $3, email = $4, updated_at = NOW()
|
updated_at: row.updated_at,
|
||||||
WHERE id = $1
|
}))
|
||||||
RETURNING id, name, max_instances::int as "max_instances!: i32",
|
|
||||||
email, created_at, updated_at
|
|
||||||
"#,
|
|
||||||
customer.id,
|
|
||||||
customer.name,
|
|
||||||
customer.max_instances as i32,
|
|
||||||
customer.email,
|
|
||||||
)
|
|
||||||
.fetch_one(&*self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: Uuid) -> Result<()> {
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
DELETE FROM customers
|
|
||||||
WHERE id = $1
|
|
||||||
"#,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.execute(&*self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::internal(format!("Database error: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -10,6 +10,7 @@ gb-core = { path = "../gb-core" }
|
||||||
gb-auth = { path = "../gb-auth" }
|
gb-auth = { path = "../gb-auth" }
|
||||||
gb-api = { path = "../gb-api" }
|
gb-api = { path = "../gb-api" }
|
||||||
|
|
||||||
|
anyhow="1.0"
|
||||||
# Testing frameworks
|
# Testing frameworks
|
||||||
goose = "0.17" # Load testing
|
goose = "0.17" # Load testing
|
||||||
criterion = { version = "0.5", features = ["async_futures"] }
|
criterion = { version = "0.5", features = ["async_futures"] }
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use anyhow;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
|
11
lib.rs
Normal file
11
lib.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
mod error;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
pub mod services; // Make services public
|
||||||
|
pub mod middleware;
|
||||||
|
|
||||||
|
pub use error::AuthError;
|
||||||
|
pub use handlers::*;
|
||||||
|
pub use models::*;
|
||||||
|
pub use services::AuthService; // This re-export is good
|
||||||
|
pub use middleware::*;
|
16
migrations/20231220000000_update_auth_schema.sql
Executable file
16
migrations/20231220000000_update_auth_schema.sql
Executable file
|
@ -0,0 +1,16 @@
|
||||||
|
-- Add password_hash column to users table if it doesn't exist
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Rename existing password column to password_hash if it exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name = 'password') THEN
|
||||||
|
ALTER TABLE users RENAME COLUMN password TO password_hash;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Add metadata column to instances table
|
||||||
|
ALTER TABLE instances
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
|
15
migrations/20231220000001_add_role_to_users.sql
Normal file
15
migrations/20231220000001_add_role_to_users.sql
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
-- Add role column to users table with a default value
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user';
|
||||||
|
|
||||||
|
-- Create type if you want to use enum
|
||||||
|
-- DO $$
|
||||||
|
-- BEGIN
|
||||||
|
-- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
|
||||||
|
-- CREATE TYPE user_role AS ENUM ('admin', 'user', 'guest');
|
||||||
|
-- END IF;
|
||||||
|
-- END $$;
|
||||||
|
|
||||||
|
-- If you want to use enum instead of varchar, uncomment this:
|
||||||
|
-- ALTER TABLE users
|
||||||
|
-- ALTER COLUMN role TYPE user_role USING role::user_role;
|
25
migrations/20231220000002_update_users_schema.sql
Normal file
25
migrations/20231220000002_update_users_schema.sql
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
-- Add missing columns to users table
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- Update role if it doesn't exist
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user';
|
||||||
|
|
||||||
|
-- Create function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Create trigger to automatically update updated_at
|
||||||
|
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||||
|
CREATE TRIGGER update_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
7
migrations/20231220000003_update_customers_schema.sql
Normal file
7
migrations/20231220000003_update_customers_schema.sql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
-- Add new columns to customers table with simple types
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD COLUMN IF NOT EXISTS email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(50) NOT NULL DEFAULT 'basic',
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
0
processor.rs
Normal file
0
processor.rs
Normal file
Loading…
Add table
Reference in a new issue